mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-09-25 03:35:59 +00:00
Compare commits
137
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40c6de8e31 | ||
|
|
2db655bd6d | ||
|
|
c49c56612b | ||
|
|
6ca074abae | ||
|
|
84430055ab | ||
|
|
432fcb3fc3 | ||
|
|
fae32361f2 | ||
|
|
bcb2e512d4 | ||
|
|
82ca04a8a7 | ||
|
|
2ef3b72224 | ||
|
|
6d319cba1d | ||
|
|
3687519ef3 | ||
|
|
3a4ac59467 | ||
|
|
1cfc135df3 | ||
|
|
5b35c51da9 | ||
|
|
ec7ddd3bad | ||
|
|
6f3e708679 | ||
|
|
869e1b89f5 | ||
|
|
9e0a3b6936 | ||
|
|
c6cb1a77d0 | ||
|
|
83010861ba | ||
|
|
daa53e5168 | ||
|
|
51befdbf87 | ||
|
|
8311b11713 | ||
|
|
19c80c7b9c | ||
|
|
a879dd1b14 | ||
|
|
a8feb9ac2b | ||
|
|
c5fbd29c0e | ||
|
|
26b1794723 | ||
|
|
371b4b70a3 | ||
|
|
b2cc38ee63 | ||
|
|
79b562cdc9 | ||
|
|
e3f089251c | ||
|
|
cf6dcbc054 | ||
|
|
2cf2b0fcac | ||
|
|
aa0cca3bb6 | ||
|
|
fb59f01058 | ||
|
|
e91a0da70a | ||
|
|
9cc617ae4c | ||
|
|
e4b0f1f1bb | ||
|
|
443c3ca0b3 | ||
|
|
55a0e5952c | ||
|
|
1dff388717 | ||
|
|
61c741f887 | ||
|
|
01dd9a05c3 | ||
|
|
8c19a2293c | ||
|
|
a1bec48dc9 | ||
|
|
7e289865b2 | ||
|
|
742c7edd57 | ||
|
|
b71a2889ef | ||
|
|
bcd75d6ce3 | ||
|
|
d4c1b0e867 | ||
|
|
b037ea9c3f | ||
|
|
b5f475cd4c | ||
|
|
eaa4d2c7b8 | ||
|
|
e160d9b048 | ||
|
|
0aeea39fbe | ||
|
|
e000636d83 | ||
|
|
8e4dc508bb | ||
|
|
e2684a93de | ||
|
|
1d89ddbb16 | ||
|
|
2bfdd44759 | ||
|
|
77966916c4 | ||
|
|
26b7455c1e | ||
|
|
8922e7b991 | ||
|
|
e6ac31fb20 | ||
|
|
c8f3c5d6aa | ||
|
|
330659e449 | ||
|
|
80043df292 | ||
|
|
ecd1ea6f8c | ||
|
|
694b8d349d | ||
|
|
ef44027f57 | ||
|
|
f3db348b01 | ||
|
|
c4eacf4591 | ||
|
|
59d4475743 | ||
|
|
22b4c4be2c | ||
|
|
5f31583a84 | ||
|
|
1d25240d8c | ||
|
|
eeb507d6ea | ||
|
|
9e9916efa5 | ||
|
|
db6b9e3684 | ||
|
|
ff24332e23 | ||
|
|
d4ff0b1767 | ||
|
|
5716f7f16b | ||
|
|
e5bd8f9e24 | ||
|
|
b56bcfb4b0 | ||
|
|
fb95b4827c | ||
|
|
a8f7226195 | ||
|
|
e6ee485352 | ||
|
|
73291a3a1c | ||
|
|
f737708f45 | ||
|
|
aa24d09aa2 | ||
|
|
fe4e77979d | ||
|
|
7a26640c26 | ||
|
|
5a777959e3 | ||
|
|
3512a80597 | ||
|
|
011770a601 | ||
|
|
6475724d2e | ||
|
|
85e9029577 | ||
|
|
b6e292cce3 | ||
|
|
c58140fb47 | ||
|
|
aebb7facfa | ||
|
|
1e2124cb99 | ||
|
|
e1cbd07d1f | ||
|
|
7750e81168 | ||
|
|
bf3edbd28f | ||
|
|
cd2cf56358 | ||
|
|
21f4a944a7 | ||
|
|
9617005136 | ||
|
|
c85d1d41b3 | ||
|
|
9e3c9228bb | ||
|
|
acd7c85ff6 | ||
|
|
8727221513 | ||
|
|
cdedaf3f63 | ||
|
|
ffe5644ddc | ||
|
|
ccc684a9ab | ||
|
|
977e502150 | ||
|
|
518d26b25f | ||
|
|
101f416268 | ||
|
|
ffa08d1c43 | ||
|
|
cf3f9169b7 | ||
|
|
8343cd5e76 | ||
|
|
005b321f62 | ||
|
|
53264f67bf | ||
|
|
f8b34e3c86 | ||
|
|
ce1bdac2bc | ||
|
|
bd8f01fb26 | ||
|
|
b590700540 | ||
|
|
48c5c23f9b | ||
|
|
f4f591d14c | ||
|
|
0c16e2211b | ||
|
|
4bfea06a12 | ||
|
|
057ee9f2c5 | ||
|
|
7f48ca54a3 | ||
|
|
ee5227130c | ||
|
|
2e0d9a2b54 | ||
|
|
c5d732773f |
+35
-54
@@ -1,29 +1,40 @@
|
||||
[target.x86_64-unknown-linux-musl]
|
||||
linker = "rust-lld"
|
||||
rustflags = ["-C", "linker-flavor=ld.lld"]
|
||||
# region Native
|
||||
|
||||
[target.x86_64-unknown-linux-gnu]
|
||||
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
|
||||
|
||||
[target.aarch64-unknown-linux-gnu]
|
||||
linker = "aarch64-linux-gnu-gcc"
|
||||
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
|
||||
|
||||
[target.aarch64-unknown-linux-ohos]
|
||||
ar = "/usr/local/ohos-sdk/linux/native/llvm/bin/llvm-ar"
|
||||
linker = "/home/runner/sdk/native/llvm/aarch64-unknown-linux-ohos-clang.sh"
|
||||
[target.'cfg(all(windows, target_env = "msvc"))']
|
||||
rustflags = ["-C", "target-feature=+crt-static"]
|
||||
|
||||
[target.aarch64-unknown-linux-ohos.env]
|
||||
PKG_CONFIG_PATH = "/usr/local/ohos-sdk/linux/native/sysroot/usr/lib/pkgconfig:/usr/local/ohos-sdk/linux/native/sysroot/usr/local/lib/pkgconfig"
|
||||
PKG_CONFIG_LIBDIR = "/usr/local/ohos-sdk/linux/native/sysroot/usr/lib:/usr/local/ohos-sdk/linux/native/sysroot/usr/local/lib"
|
||||
PKG_CONFIG_SYSROOT_DIR = "/usr/local/ohos-sdk/linux/native/sysroot"
|
||||
SYSROOT = "/usr/local/ohos-sdk/linux/native/sysroot"
|
||||
# region
|
||||
|
||||
# region CI
|
||||
|
||||
[target.x86_64-unknown-linux-musl]
|
||||
rustflags = ["-C", "target-feature=+crt-static"]
|
||||
|
||||
[target.aarch64-unknown-linux-musl]
|
||||
linker = "aarch64-unknown-linux-musl-gcc"
|
||||
rustflags = ["-C", "target-feature=+crt-static"]
|
||||
|
||||
[target.riscv64gc-unknown-linux-musl]
|
||||
linker = "riscv64-unknown-linux-musl-gcc"
|
||||
rustflags = ["-C", "target-feature=+crt-static"]
|
||||
|
||||
[target.'cfg(all(windows, target_env = "msvc"))']
|
||||
[target.armv7-unknown-linux-musleabihf]
|
||||
rustflags = ["-C", "target-feature=+crt-static"]
|
||||
|
||||
[target.armv7-unknown-linux-musleabi]
|
||||
rustflags = ["-C", "target-feature=+crt-static"]
|
||||
|
||||
[target.arm-unknown-linux-musleabihf]
|
||||
rustflags = ["-C", "target-feature=+crt-static"]
|
||||
|
||||
[target.arm-unknown-linux-musleabi]
|
||||
rustflags = ["-C", "target-feature=+crt-static"]
|
||||
|
||||
[target.loongarch64-unknown-linux-musl]
|
||||
rustflags = ["-C", "target-feature=+crt-static"]
|
||||
|
||||
[target.mipsel-unknown-linux-musl]
|
||||
@@ -64,44 +75,14 @@ rustflags = [
|
||||
"gcc",
|
||||
]
|
||||
|
||||
[target.armv7-unknown-linux-musleabihf]
|
||||
linker = "armv7-unknown-linux-musleabihf-gcc"
|
||||
rustflags = ["-C", "target-feature=+crt-static"]
|
||||
[target.aarch64-unknown-linux-ohos]
|
||||
ar = "/usr/local/ohos-sdk/linux/native/llvm/bin/llvm-ar"
|
||||
linker = "/home/runner/sdk/native/llvm/aarch64-unknown-linux-ohos-clang.sh"
|
||||
|
||||
[target.armv7-unknown-linux-musleabi]
|
||||
linker = "armv7-unknown-linux-musleabi-gcc"
|
||||
rustflags = ["-C", "target-feature=+crt-static"]
|
||||
[target.aarch64-unknown-linux-ohos.env]
|
||||
PKG_CONFIG_PATH = "/usr/local/ohos-sdk/linux/native/sysroot/usr/lib/pkgconfig:/usr/local/ohos-sdk/linux/native/sysroot/usr/local/lib/pkgconfig"
|
||||
PKG_CONFIG_LIBDIR = "/usr/local/ohos-sdk/linux/native/sysroot/usr/lib:/usr/local/ohos-sdk/linux/native/sysroot/usr/local/lib"
|
||||
PKG_CONFIG_SYSROOT_DIR = "/usr/local/ohos-sdk/linux/native/sysroot"
|
||||
SYSROOT = "/usr/local/ohos-sdk/linux/native/sysroot"
|
||||
|
||||
[target.loongarch64-unknown-linux-musl]
|
||||
linker = "loongarch64-unknown-linux-musl-gcc"
|
||||
rustflags = ["-C", "target-feature=+crt-static"]
|
||||
|
||||
[target.arm-unknown-linux-musleabihf]
|
||||
linker = "arm-unknown-linux-musleabihf-gcc"
|
||||
rustflags = [
|
||||
"-C",
|
||||
"target-feature=+crt-static",
|
||||
"-L",
|
||||
"./musl_gcc/arm-unknown-linux-musleabihf/arm-unknown-linux-musleabihf/lib",
|
||||
"-L",
|
||||
"./musl_gcc/arm-unknown-linux-musleabihf/lib/gcc/arm-unknown-linux-musleabihf/15.1.0",
|
||||
"-l",
|
||||
"atomic",
|
||||
"-l",
|
||||
"gcc",
|
||||
]
|
||||
|
||||
[target.arm-unknown-linux-musleabi]
|
||||
linker = "arm-unknown-linux-musleabi-gcc"
|
||||
rustflags = [
|
||||
"-C",
|
||||
"target-feature=+crt-static",
|
||||
"-L",
|
||||
"./musl_gcc/arm-unknown-linux-musleabi/arm-unknown-linux-musleabi/lib",
|
||||
"-L",
|
||||
"./musl_gcc/arm-unknown-linux-musleabi/lib/gcc/arm-unknown-linux-musleabi/15.1.0",
|
||||
"-l",
|
||||
"atomic",
|
||||
"-l",
|
||||
"gcc",
|
||||
]
|
||||
# endregion
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
name: prepare-build
|
||||
author: Luna
|
||||
description: Prepare build environment
|
||||
inputs:
|
||||
target:
|
||||
description: 'The target to build for'
|
||||
required: false
|
||||
pnpm:
|
||||
description: 'Whether to run pnpm build'
|
||||
required: true
|
||||
default: 'true'
|
||||
pnpm-build-filter:
|
||||
description: 'The filter argument for pnpm build (e.g. ./easytier-web/*)'
|
||||
required: false
|
||||
default: './easytier-web/*'
|
||||
gui:
|
||||
description: 'Whether to prepare the GUI build environment'
|
||||
required: true
|
||||
default: 'true'
|
||||
token:
|
||||
description: 'GitHub token, used by setup-protoc action'
|
||||
required: false
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- run: mkdir -p easytier-gui/dist
|
||||
shell: bash
|
||||
|
||||
- name: Install dependencies
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -qqy build-essential mold musl-tools
|
||||
shell: bash
|
||||
|
||||
- name: Setup Frontend Environment
|
||||
if: ${{ inputs.pnpm == 'true' }}
|
||||
uses: ./.github/actions/prepare-pnpm
|
||||
with:
|
||||
build-filter: ${{ inputs.pnpm-build-filter }}
|
||||
|
||||
- name: Install GUI dependencies (Linux)
|
||||
if: ${{ inputs.gui == 'true' && runner.os == 'Linux' }}
|
||||
run: |
|
||||
sudo apt-get install -qq xdg-utils \
|
||||
libappindicator3-dev \
|
||||
libgtk-3-dev \
|
||||
librsvg2-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libxdo-dev
|
||||
shell: bash
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: 1.95
|
||||
target: ${{ !contains(inputs.target, 'mips') && inputs.target || '' }}
|
||||
components: ${{ contains(inputs.target, 'mips') && 'rust-src' || '' }}
|
||||
cache: false
|
||||
rustflags: ''
|
||||
|
||||
- name: Install Rust (MIPS)
|
||||
if: ${{ contains(inputs.target, 'mips') }}
|
||||
run: |
|
||||
MUSL_TARGET=${{ inputs.target }}sf
|
||||
mkdir -p ./musl_gcc
|
||||
wget --inet4-only -c https://github.com/cross-tools/musl-cross/releases/download/20250520/${MUSL_TARGET}.tar.xz -P ./musl_gcc/
|
||||
tar xf ./musl_gcc/${MUSL_TARGET}.tar.xz -C ./musl_gcc/
|
||||
sudo ln -sf $(pwd)/musl_gcc/${MUSL_TARGET}/bin/*gcc /usr/bin/
|
||||
sudo ln -sf $(pwd)/musl_gcc/${MUSL_TARGET}/include/ /usr/include/musl-cross
|
||||
sudo ln -sf $(pwd)/musl_gcc/${MUSL_TARGET}/${MUSL_TARGET}/sysroot/ ./musl_gcc/sysroot
|
||||
sudo chmod -R a+rwx ./musl_gcc
|
||||
|
||||
if [[ -d "./musl_gcc/sysroot" ]]; then
|
||||
echo "BINDGEN_EXTRA_CLANG_ARGS=--sysroot=$(readlink -f ./musl_gcc/sysroot)" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
cd "$PWD/musl_gcc/${MUSL_TARGET}/lib/gcc/${MUSL_TARGET}/15.1.0" || exit 255
|
||||
# for panic-abort
|
||||
cp libgcc_eh.a libunwind.a
|
||||
|
||||
# for mimalloc
|
||||
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 }}
|
||||
@@ -0,0 +1,48 @@
|
||||
name: 'Setup pnpm'
|
||||
author: Luna
|
||||
description: 'Setup Node.js, pnpm, and install dependencies'
|
||||
|
||||
inputs:
|
||||
build-filter:
|
||||
description: 'The filter argument for pnpm build (e.g. ./easytier-web/*)'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v5
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install and build
|
||||
shell: bash
|
||||
run: |
|
||||
pnpm -r install
|
||||
if [ -n "${{ inputs.build-filter }}" ]; then
|
||||
echo "Building with filter: ${{ inputs.build-filter }}"
|
||||
pnpm -r --filter "${{ inputs.build-filter }}" build
|
||||
else
|
||||
echo "No build filter provided, building all packages"
|
||||
pnpm -r build
|
||||
fi
|
||||
+142
-179
@@ -2,9 +2,14 @@ name: EasyTier Core
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["develop", "main", "releases/**"]
|
||||
branches: [ "develop", "main", "releases/**" ]
|
||||
pull_request:
|
||||
branches: ["develop", "main"]
|
||||
branches: [ "develop", "main" ]
|
||||
types: [ opened, synchronize, reopened, ready_for_review ]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
@@ -18,6 +23,7 @@ jobs:
|
||||
pre_job:
|
||||
# continue-on-error: true # Uncomment once integration is finished
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
# Map a step output to a job output
|
||||
outputs:
|
||||
# do not skip push on branch starts with releases/
|
||||
@@ -30,85 +36,69 @@ 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/workflows/install_rust.sh"]'
|
||||
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", ".github/workflows/core.yml", ".github/actions/**", "easytier-web/**"]'
|
||||
build_web:
|
||||
runs-on: ubuntu-latest
|
||||
needs: pre_job
|
||||
if: needs.pre_job.outputs.should_skip != 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- name: Setup Frontend Environment
|
||||
uses: ./.github/actions/prepare-pnpm
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: |
|
||||
pnpm -r install
|
||||
pnpm -r --filter "./easytier-web/*" build
|
||||
build-filter: './easytier-web/*'
|
||||
|
||||
- name: Archive artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: easytier-web-dashboard
|
||||
path: |
|
||||
easytier-web/frontend/dist/*
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
fail-fast: true
|
||||
matrix:
|
||||
include:
|
||||
- TARGET: aarch64-unknown-linux-musl
|
||||
OS: ubuntu-22.04
|
||||
ARTIFACT_NAME: linux-aarch64
|
||||
- TARGET: x86_64-unknown-linux-musl
|
||||
OS: ubuntu-22.04
|
||||
OS: ubuntu-24.04
|
||||
ARTIFACT_NAME: linux-x86_64
|
||||
- TARGET: riscv64gc-unknown-linux-musl
|
||||
OS: ubuntu-22.04
|
||||
ARTIFACT_NAME: linux-riscv64
|
||||
- TARGET: mips-unknown-linux-musl
|
||||
OS: ubuntu-22.04
|
||||
ARTIFACT_NAME: linux-mips
|
||||
- TARGET: mipsel-unknown-linux-musl
|
||||
OS: ubuntu-22.04
|
||||
ARTIFACT_NAME: linux-mipsel
|
||||
- TARGET: armv7-unknown-linux-musleabihf # raspberry pi 2-3-4, not tested
|
||||
OS: ubuntu-22.04
|
||||
ARTIFACT_NAME: linux-armv7hf
|
||||
- TARGET: armv7-unknown-linux-musleabi # raspberry pi 2-3-4, not tested
|
||||
OS: ubuntu-22.04
|
||||
ARTIFACT_NAME: linux-armv7
|
||||
- TARGET: arm-unknown-linux-musleabihf # raspberry pi 0-1, not tested
|
||||
OS: ubuntu-22.04
|
||||
ARTIFACT_NAME: linux-armhf
|
||||
- TARGET: arm-unknown-linux-musleabi # raspberry pi 0-1, not tested
|
||||
OS: ubuntu-22.04
|
||||
ARTIFACT_NAME: linux-arm
|
||||
- TARGET: aarch64-unknown-linux-musl
|
||||
OS: ubuntu-24.04-arm
|
||||
ARTIFACT_NAME: linux-aarch64
|
||||
|
||||
- TARGET: riscv64gc-unknown-linux-musl
|
||||
OS: ubuntu-24.04
|
||||
ARTIFACT_NAME: linux-riscv64
|
||||
- TARGET: loongarch64-unknown-linux-musl
|
||||
OS: ubuntu-24.04
|
||||
ARTIFACT_NAME: linux-loongarch64
|
||||
|
||||
- TARGET: armv7-unknown-linux-musleabihf # raspberry pi 2-3-4, not tested
|
||||
OS: ubuntu-24.04
|
||||
ARTIFACT_NAME: linux-armv7hf
|
||||
- TARGET: armv7-unknown-linux-musleabi # raspberry pi 2-3-4, not tested
|
||||
OS: ubuntu-24.04
|
||||
ARTIFACT_NAME: linux-armv7
|
||||
- TARGET: arm-unknown-linux-musleabihf # raspberry pi 0-1, not tested
|
||||
OS: ubuntu-24.04
|
||||
ARTIFACT_NAME: linux-armhf
|
||||
- TARGET: arm-unknown-linux-musleabi # raspberry pi 0-1, not tested
|
||||
OS: ubuntu-24.04
|
||||
ARTIFACT_NAME: linux-arm
|
||||
|
||||
- TARGET: mips-unknown-linux-musl
|
||||
OS: ubuntu-24.04
|
||||
ARTIFACT_NAME: linux-mips
|
||||
- TARGET: mipsel-unknown-linux-musl
|
||||
OS: ubuntu-24.04
|
||||
ARTIFACT_NAME: linux-mipsel
|
||||
|
||||
- TARGET: x86_64-unknown-freebsd
|
||||
OS: ubuntu-24.04
|
||||
ARTIFACT_NAME: freebsd-13.2-x86_64
|
||||
BSD_VERSION: 13.2
|
||||
|
||||
- TARGET: x86_64-apple-darwin
|
||||
OS: macos-latest
|
||||
ARTIFACT_NAME: macos-x86_64
|
||||
@@ -119,17 +109,12 @@ jobs:
|
||||
- TARGET: x86_64-pc-windows-msvc
|
||||
OS: windows-latest
|
||||
ARTIFACT_NAME: windows-x86_64
|
||||
- TARGET: aarch64-pc-windows-msvc
|
||||
OS: windows-latest
|
||||
ARTIFACT_NAME: windows-arm64
|
||||
- TARGET: i686-pc-windows-msvc
|
||||
OS: windows-latest
|
||||
ARTIFACT_NAME: windows-i686
|
||||
|
||||
- TARGET: x86_64-unknown-freebsd
|
||||
OS: ubuntu-22.04
|
||||
ARTIFACT_NAME: freebsd-13.2-x86_64
|
||||
BSD_VERSION: 13.2
|
||||
- TARGET: aarch64-pc-windows-msvc
|
||||
OS: windows-11-arm
|
||||
ARTIFACT_NAME: windows-arm64
|
||||
|
||||
runs-on: ${{ matrix.OS }}
|
||||
env:
|
||||
@@ -142,7 +127,7 @@ jobs:
|
||||
- build_web
|
||||
if: needs.pre_job.outputs.should_skip != 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Set current ref as env variable
|
||||
run: |
|
||||
@@ -154,158 +139,128 @@ jobs:
|
||||
name: easytier-web-dashboard
|
||||
path: easytier-web/frontend/dist/
|
||||
|
||||
- name: Prepare build environment
|
||||
uses: ./.github/actions/prepare-build
|
||||
with:
|
||||
target: ${{ matrix.TARGET }}
|
||||
gui: true
|
||||
pnpm: true
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: ${{ ! endsWith(matrix.TARGET, 'freebsd') }}
|
||||
with:
|
||||
# The prefix cache key, this can be changed to start a new cache manually.
|
||||
# default: "v0-rust"
|
||||
prefix-key: ""
|
||||
shared-key: "core-registry"
|
||||
cache-targets: "false"
|
||||
|
||||
- name: Setup protoc
|
||||
uses: arduino/setup-protoc@v3
|
||||
- uses: mlugg/setup-zig@v2
|
||||
if: ${{ contains(matrix.OS, 'ubuntu') }}
|
||||
|
||||
- uses: taiki-e/install-action@v2
|
||||
if: ${{ contains(matrix.OS, 'ubuntu') }}
|
||||
with:
|
||||
# GitHub repo token to use to avoid rate limiter
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
tool: cargo-zigbuild
|
||||
|
||||
- name: Build Core & Cli
|
||||
if: ${{ ! endsWith(matrix.TARGET, 'freebsd') }}
|
||||
run: |
|
||||
bash ./.github/workflows/install_rust.sh
|
||||
|
||||
# loongarch need llvm-18
|
||||
if [[ $TARGET =~ ^loongarch.*$ ]]; then
|
||||
sudo apt-get install -qq llvm-18 clang-18
|
||||
export LLVM_CONFIG_PATH=/usr/lib/llvm-18/bin/llvm-config
|
||||
fi
|
||||
# we set the sysroot when sysroot is a dir
|
||||
# this dir is a soft link generated by install_rust.sh
|
||||
# kcp-sys need this to gen ffi bindings. without this clang may fail to find some libc headers such as bits/libc-header-start.h
|
||||
if [[ -d "./musl_gcc/sysroot" ]]; then
|
||||
export BINDGEN_EXTRA_CLANG_ARGS=--sysroot=$(readlink -f ./musl_gcc/sysroot)
|
||||
fi
|
||||
|
||||
if [[ $OS =~ ^ubuntu.*$ && $TARGET =~ ^mips.*$ ]]; then
|
||||
cargo +nightly-2025-09-01 build -r --target $TARGET -Z build-std=std,panic_abort --package=easytier --features=jemalloc
|
||||
- name: Build
|
||||
if: ${{ !contains(matrix.TARGET, 'mips') }}
|
||||
run: |
|
||||
if [[ "$TARGET" == *windows* ]]; then
|
||||
SUFFIX=.exe
|
||||
else
|
||||
if [[ $OS =~ ^windows.*$ ]]; then
|
||||
SUFFIX=.exe
|
||||
CORE_FEATURES="--features=mimalloc"
|
||||
elif [[ $TARGET =~ ^riscv64.*$ || $TARGET =~ ^loongarch64.*$ || $TARGET =~ ^aarch64.*$ ]]; then
|
||||
CORE_FEATURES="--features=mimalloc"
|
||||
else
|
||||
CORE_FEATURES="--features=jemalloc"
|
||||
fi
|
||||
cargo build --release --target $TARGET --package=easytier-web --features=embed
|
||||
mv ./target/$TARGET/release/easytier-web"$SUFFIX" ./target/$TARGET/release/easytier-web-embed"$SUFFIX"
|
||||
cargo build --release --target $TARGET $CORE_FEATURES
|
||||
SUFFIX=""
|
||||
fi
|
||||
|
||||
# Copied and slightly modified from @lmq8267 (https://github.com/lmq8267)
|
||||
- name: Build Core & Cli (X86_64 FreeBSD)
|
||||
uses: vmactions/freebsd-vm@670398e4236735b8b65805c3da44b7a511fb8b27
|
||||
if: ${{ endsWith(matrix.TARGET, 'freebsd') }}
|
||||
if [[ "$TARGET" =~ (x86_64-unknown-linux-musl|aarch64-unknown-linux-musl|windows|darwin) ]]; then
|
||||
BUILD=build
|
||||
else
|
||||
BUILD=zigbuild
|
||||
fi
|
||||
|
||||
if [[ "$TARGET" =~ ^(riscv64|loongarch64|aarch64).*$ || "$TARGET" =~ windows ]]; then
|
||||
FEATURES="mimalloc"
|
||||
else
|
||||
FEATURES="jemalloc"
|
||||
fi
|
||||
|
||||
cargo $BUILD --release --target $TARGET --package=easytier-web --features=embed
|
||||
mv ./target/$TARGET/release/easytier-web"$SUFFIX" ./target/$TARGET/release/easytier-web-embed"$SUFFIX"
|
||||
|
||||
cargo $BUILD --release --target $TARGET --features=$FEATURES
|
||||
|
||||
- name: Build (MIPS)
|
||||
if: ${{ contains(matrix.TARGET, 'mips') }}
|
||||
env:
|
||||
TARGET: ${{ matrix.TARGET }}
|
||||
with:
|
||||
envs: TARGET
|
||||
release: ${{ matrix.BSD_VERSION }}
|
||||
arch: x86_64
|
||||
usesh: true
|
||||
mem: 6144
|
||||
cpu: 4
|
||||
run: |
|
||||
uname -a
|
||||
echo $SHELL
|
||||
pwd
|
||||
ls -lah
|
||||
whoami
|
||||
env | sort
|
||||
|
||||
pkg install -y git protobuf llvm-devel sudo curl
|
||||
curl --proto 'https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
. $HOME/.cargo/env
|
||||
|
||||
rustup set auto-self-update disable
|
||||
|
||||
rustup install 1.89
|
||||
rustup default 1.89
|
||||
|
||||
export CC=clang
|
||||
export CXX=clang++
|
||||
export CARGO_TERM_COLOR=always
|
||||
|
||||
cargo build --release --verbose --target $TARGET --package=easytier-web --features=embed
|
||||
mv ./target/$TARGET/release/easytier-web ./target/$TARGET/release/easytier-web-embed
|
||||
cargo build --release --verbose --target $TARGET --features=mimalloc
|
||||
RUSTC_BOOTSTRAP: 1
|
||||
run: |
|
||||
cargo build -r --target $TARGET -Z build-std=std,panic_abort --package=easytier --features=jemalloc
|
||||
|
||||
- name: Compress
|
||||
run: |
|
||||
mkdir -p ./artifacts/objects/
|
||||
|
||||
# windows is the only OS using a different convention for executable file name
|
||||
if [[ $OS =~ ^windows.*$ && $TARGET =~ ^x86_64.*$ ]]; then
|
||||
if [[ $OS =~ ^windows.*$ ]]; then
|
||||
SUFFIX=.exe
|
||||
cp easytier/third_party/x86_64/* ./artifacts/objects/
|
||||
elif [[ $OS =~ ^windows.*$ && $TARGET =~ ^i686.*$ ]]; then
|
||||
SUFFIX=.exe
|
||||
cp easytier/third_party/i686/* ./artifacts/objects/
|
||||
elif [[ $OS =~ ^windows.*$ && $TARGET =~ ^aarch64.*$ ]]; then
|
||||
SUFFIX=.exe
|
||||
cp easytier/third_party/arm64/* ./artifacts/objects/
|
||||
case $TARGET in
|
||||
x86_64*) ARCH_DIR=x86_64 ;;
|
||||
i686*) ARCH_DIR=i686 ;;
|
||||
aarch64*) ARCH_DIR=arm64 ;;
|
||||
esac
|
||||
if [[ -n "$ARCH_DIR" ]]; then
|
||||
find "easytier/third_party/${ARCH_DIR}" -maxdepth 1 -type f \( -name "*.dll" -o -name "*.sys" \) -exec cp {} ./artifacts/objects/ \;
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ $GITHUB_REF_TYPE =~ ^tag$ ]]; then
|
||||
TAG=$GITHUB_REF_NAME
|
||||
else
|
||||
TAG=$GITHUB_SHA
|
||||
fi
|
||||
|
||||
if [[ $OS =~ ^ubuntu.*$ && ! $TARGET =~ (loongarch|freebsd) ]]; then
|
||||
HOST_ARCH=$(uname -m)
|
||||
case $HOST_ARCH in
|
||||
x86_64) UPX_ARCH="amd64" ;;
|
||||
aarch64) UPX_ARCH="arm64" ;;
|
||||
*) UPX_ARCH="amd64" ;;
|
||||
esac
|
||||
|
||||
if [[ $OS =~ ^ubuntu.*$ && ! $TARGET =~ ^.*freebsd$ && ! $TARGET =~ ^loongarch.*$ && ! $TARGET =~ ^riscv64.*$ ]]; then
|
||||
UPX_VERSION=4.2.4
|
||||
curl -L https://github.com/upx/upx/releases/download/v${UPX_VERSION}/upx-${UPX_VERSION}-amd64_linux.tar.xz -s | tar xJvf -
|
||||
cp upx-${UPX_VERSION}-amd64_linux/upx .
|
||||
./upx --lzma --best ./target/$TARGET/release/easytier-core"$SUFFIX"
|
||||
./upx --lzma --best ./target/$TARGET/release/easytier-cli"$SUFFIX"
|
||||
UPX_VERSION=5.1.1
|
||||
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" .
|
||||
UPX_BIN=./upx
|
||||
fi
|
||||
|
||||
mv ./target/$TARGET/release/easytier-core"$SUFFIX" ./artifacts/objects/
|
||||
mv ./target/$TARGET/release/easytier-cli"$SUFFIX" ./artifacts/objects/
|
||||
if [[ ! $TARGET =~ ^mips.*$ ]]; then
|
||||
mv ./target/$TARGET/release/easytier-web"$SUFFIX" ./artifacts/objects/
|
||||
mv ./target/$TARGET/release/easytier-web-embed"$SUFFIX" ./artifacts/objects/
|
||||
fi
|
||||
for BIN in ./target/$TARGET/release/easytier-{core,cli,web,web-embed}"$SUFFIX"; do
|
||||
if [[ -f "$BIN" ]]; then
|
||||
if [[ -n "$UPX_BIN" ]]; then
|
||||
$UPX_BIN --lzma --best "$BIN" || true
|
||||
fi
|
||||
|
||||
mv "$BIN" ./artifacts/objects/
|
||||
fi
|
||||
done
|
||||
|
||||
mv ./artifacts/objects/* ./artifacts/
|
||||
rm -rf ./artifacts/objects/
|
||||
|
||||
- name: Archive artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: easytier-${{ matrix.ARTIFACT_NAME }}
|
||||
path: |
|
||||
./artifacts/*
|
||||
|
||||
core-result:
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && always()
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- pre_job
|
||||
- build_web
|
||||
- build
|
||||
steps:
|
||||
- name: Mark result as failed
|
||||
if: needs.build.result != 'success'
|
||||
run: exit 1
|
||||
|
||||
magisk_build:
|
||||
needs:
|
||||
- pre_job
|
||||
- build_web
|
||||
- build
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && always()
|
||||
build_magisk:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ pre_job, build_web, build ]
|
||||
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4 # 必须先检出代码才能获取模块配置
|
||||
uses: actions/checkout@v5 # 必须先检出代码才能获取模块配置
|
||||
|
||||
# 下载二进制文件到独立目录
|
||||
- name: Download Linux aarch64 binaries
|
||||
@@ -322,10 +277,9 @@ jobs:
|
||||
cp ./downloaded-binaries/easytier-cli ./easytier-contrib/easytier-magisk/
|
||||
cp ./downloaded-binaries/easytier-web ./easytier-contrib/easytier-magisk/
|
||||
|
||||
|
||||
# 上传生成的模块
|
||||
- name: Upload Magisk Module
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: Easytier-Magisk
|
||||
path: |
|
||||
@@ -333,3 +287,12 @@ jobs:
|
||||
!./easytier-contrib/easytier-magisk/build.sh
|
||||
!./easytier-contrib/easytier-magisk/magisk_update.json
|
||||
if-no-files-found: error
|
||||
|
||||
core-result:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ pre_job, build_web, build, build_magisk ]
|
||||
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
|
||||
steps:
|
||||
- name: Mark result as failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
|
||||
@@ -11,7 +11,7 @@ on:
|
||||
image_tag:
|
||||
description: 'Tag for this image build'
|
||||
type: string
|
||||
default: 'v2.5.0'
|
||||
default: 'v2.6.0'
|
||||
required: true
|
||||
mark_latest:
|
||||
description: 'Mark this image as latest'
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
-
|
||||
name: Validate inputs
|
||||
run: |
|
||||
|
||||
+48
-112
@@ -5,7 +5,12 @@ on:
|
||||
branches: ["develop", "main", "releases/**"]
|
||||
pull_request:
|
||||
branches: ["develop", "main"]
|
||||
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
@@ -18,6 +23,7 @@ jobs:
|
||||
pre_job:
|
||||
# continue-on-error: true # Uncomment once integration is finished
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
# Map a step output to a job output
|
||||
outputs:
|
||||
should_skip: ${{ steps.skip_check.outputs.should_skip == 'true' && !startsWith(github.ref_name, 'releases/') }}
|
||||
@@ -29,20 +35,20 @@ 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/workflows/install_rust.sh", ".github/workflows/install_gui_dep.sh"]'
|
||||
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-gui/**", ".github/workflows/gui.yml", ".github/actions/**", "easytier-web/frontend-lib/**"]'
|
||||
build-gui:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
fail-fast: true
|
||||
matrix:
|
||||
include:
|
||||
- TARGET: aarch64-unknown-linux-musl
|
||||
OS: ubuntu-22.04
|
||||
GUI_TARGET: aarch64-unknown-linux-gnu
|
||||
ARTIFACT_NAME: linux-aarch64
|
||||
- TARGET: x86_64-unknown-linux-musl
|
||||
OS: ubuntu-22.04
|
||||
OS: ubuntu-24.04
|
||||
GUI_TARGET: x86_64-unknown-linux-gnu
|
||||
ARTIFACT_NAME: linux-x86_64
|
||||
- TARGET: aarch64-unknown-linux-musl
|
||||
OS: ubuntu-24.04-arm
|
||||
GUI_TARGET: aarch64-unknown-linux-gnu
|
||||
ARTIFACT_NAME: linux-aarch64
|
||||
|
||||
- TARGET: x86_64-apple-darwin
|
||||
OS: macos-latest
|
||||
@@ -57,16 +63,14 @@ jobs:
|
||||
OS: windows-latest
|
||||
GUI_TARGET: x86_64-pc-windows-msvc
|
||||
ARTIFACT_NAME: windows-x86_64
|
||||
|
||||
- TARGET: aarch64-pc-windows-msvc
|
||||
OS: windows-latest
|
||||
GUI_TARGET: aarch64-pc-windows-msvc
|
||||
ARTIFACT_NAME: windows-arm64
|
||||
|
||||
- TARGET: i686-pc-windows-msvc
|
||||
OS: windows-latest
|
||||
GUI_TARGET: i686-pc-windows-msvc
|
||||
ARTIFACT_NAME: windows-i686
|
||||
- TARGET: aarch64-pc-windows-msvc
|
||||
OS: windows-11-arm
|
||||
GUI_TARGET: aarch64-pc-windows-msvc
|
||||
ARTIFACT_NAME: windows-arm64
|
||||
|
||||
runs-on: ${{ matrix.OS }}
|
||||
env:
|
||||
@@ -78,103 +82,39 @@ jobs:
|
||||
needs: pre_job
|
||||
if: needs.pre_job.outputs.should_skip != 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Install GUI dependencies (x86 only)
|
||||
if: ${{ matrix.TARGET == 'x86_64-unknown-linux-musl' }}
|
||||
run: bash ./.github/workflows/install_gui_dep.sh
|
||||
|
||||
- name: Install GUI cross compile (aarch64 only)
|
||||
if: ${{ matrix.TARGET == 'aarch64-unknown-linux-musl' }}
|
||||
run: |
|
||||
# see https://tauri.app/v1/guides/building/linux/
|
||||
echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy main restricted" | sudo tee /etc/apt/sources.list
|
||||
echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy-updates main restricted" | sudo tee -a /etc/apt/sources.list
|
||||
echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy universe" | sudo tee -a /etc/apt/sources.list
|
||||
echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy-updates universe" | sudo tee -a /etc/apt/sources.list
|
||||
echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy multiverse" | sudo tee -a /etc/apt/sources.list
|
||||
echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy-updates multiverse" | sudo tee -a /etc/apt/sources.list
|
||||
echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy-backports main restricted universe multiverse" | sudo tee -a /etc/apt/sources.list
|
||||
echo "deb [arch=amd64] http://security.ubuntu.com/ubuntu/ jammy-security main restricted" | sudo tee -a /etc/apt/sources.list
|
||||
echo "deb [arch=amd64] http://security.ubuntu.com/ubuntu/ jammy-security universe" | sudo tee -a /etc/apt/sources.list
|
||||
echo "deb [arch=amd64] http://security.ubuntu.com/ubuntu/ jammy-security multiverse" | sudo tee -a /etc/apt/sources.list
|
||||
|
||||
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy main restricted" | sudo tee -a /etc/apt/sources.list
|
||||
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-updates main restricted" | sudo tee -a /etc/apt/sources.list
|
||||
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy universe" | sudo tee -a /etc/apt/sources.list
|
||||
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-updates universe" | sudo tee -a /etc/apt/sources.list
|
||||
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy multiverse" | sudo tee -a /etc/apt/sources.list
|
||||
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-updates multiverse" | sudo tee -a /etc/apt/sources.list
|
||||
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-backports main restricted universe multiverse" | sudo tee -a /etc/apt/sources.list
|
||||
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-security main restricted" | sudo tee -a /etc/apt/sources.list
|
||||
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-security universe" | sudo tee -a /etc/apt/sources.list
|
||||
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-security multiverse" | sudo tee -a /etc/apt/sources.list
|
||||
|
||||
sudo dpkg --add-architecture arm64
|
||||
sudo apt update
|
||||
sudo apt install aptitude
|
||||
sudo aptitude install -y libgstreamer1.0-0:arm64 gstreamer1.0-plugins-base:arm64 gstreamer1.0-plugins-good:arm64 \
|
||||
libgstreamer-gl1.0-0:arm64 libgstreamer-plugins-base1.0-0:arm64 libgstreamer-plugins-good1.0-0:arm64 libwebkit2gtk-4.1-0:arm64 \
|
||||
libwebkit2gtk-4.1-dev:arm64 libssl-dev:arm64 gcc-aarch64-linux-gnu libsoup-3.0-dev:arm64 libjavascriptcoregtk-4.1-dev:arm64
|
||||
echo "PKG_CONFIG_SYSROOT_DIR=/usr/aarch64-linux-gnu/" >> "$GITHUB_ENV"
|
||||
echo "PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig/" >> "$GITHUB_ENV"
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Set current ref as env variable
|
||||
run: |
|
||||
echo "GIT_DESC=$(git log -1 --format=%cd.%h --date=format:%Y-%m-%d_%H:%M:%S)" >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- name: Prepare build environment
|
||||
uses: ./.github/actions/prepare-build
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: |
|
||||
pnpm -r install
|
||||
pnpm -r build
|
||||
target: ${{ matrix.TARGET }}
|
||||
gui: true
|
||||
pnpm: true
|
||||
pnpm-build-filter: ''
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
# The prefix cache key, this can be changed to start a new cache manually.
|
||||
# default: "v0-rust"
|
||||
prefix-key: ""
|
||||
|
||||
- name: Install rust target
|
||||
run: bash ./.github/workflows/install_rust.sh
|
||||
|
||||
- name: Setup protoc
|
||||
uses: arduino/setup-protoc@v3
|
||||
with:
|
||||
# GitHub repo token to use to avoid rate limiter
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
shared-key: "gui-registry"
|
||||
cache-targets: "false"
|
||||
|
||||
- name: copy correct DLLs
|
||||
if: ${{ matrix.OS == 'windows-latest' }}
|
||||
if: ${{ contains(matrix.GUI_TARGET, 'windows') }}
|
||||
run: |
|
||||
if [[ $GUI_TARGET =~ ^aarch64.*$ ]]; then
|
||||
cp ./easytier/third_party/arm64/* ./easytier-gui/src-tauri/
|
||||
elif [[ $GUI_TARGET =~ ^i686.*$ ]]; then
|
||||
cp ./easytier/third_party/i686/* ./easytier-gui/src-tauri/
|
||||
else
|
||||
cp ./easytier/third_party/x86_64/* ./easytier-gui/src-tauri/
|
||||
case $TARGET in
|
||||
x86_64*) ARCH_DIR=x86_64 ;;
|
||||
i686*) ARCH_DIR=i686 ;;
|
||||
aarch64*) ARCH_DIR=arm64 ;;
|
||||
esac
|
||||
if [[ -n "$ARCH_DIR" ]]; then
|
||||
find "./easytier/third_party/${ARCH_DIR}" -maxdepth 1 -type f \( -name "*.dll" -o -name "*.sys" \) -exec cp {} ./easytier-gui/src-tauri/ \;
|
||||
fi
|
||||
|
||||
- name: Build GUI
|
||||
@@ -182,10 +122,9 @@ jobs:
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
with:
|
||||
projectPath: ./easytier-gui
|
||||
# https://tauri.app/v1/guides/building/linux/#cross-compiling-tauri-applications-for-arm-based-devices
|
||||
args: --verbose --target ${{ matrix.GUI_TARGET }} ${{ matrix.OS == 'ubuntu-22.04' && contains(matrix.TARGET, 'aarch64') && '--bundles deb' || '' }}
|
||||
args: --verbose --target ${{ matrix.GUI_TARGET }}
|
||||
|
||||
- name: Compress
|
||||
- name: Collect artifact
|
||||
run: |
|
||||
mkdir -p ./artifacts/objects/
|
||||
|
||||
@@ -194,36 +133,33 @@ jobs:
|
||||
else
|
||||
TAG=$GITHUB_SHA
|
||||
fi
|
||||
|
||||
# copy gui bundle, gui is built without specific target
|
||||
if [[ $OS =~ ^windows.*$ ]]; then
|
||||
if [[ $GUI_TARGET =~ windows ]]; then
|
||||
mv ./target/$GUI_TARGET/release/bundle/nsis/*.exe ./artifacts/objects/
|
||||
elif [[ $OS =~ ^macos.*$ ]]; then
|
||||
elif [[ $GUI_TARGET =~ darwin ]]; then
|
||||
mv ./target/$GUI_TARGET/release/bundle/dmg/*.dmg ./artifacts/objects/
|
||||
elif [[ $OS =~ ^ubuntu.*$ && ! $TARGET =~ ^mips.*$ ]]; then
|
||||
elif [[ $GUI_TARGET =~ linux ]]; then
|
||||
mv ./target/$GUI_TARGET/release/bundle/deb/*.deb ./artifacts/objects/
|
||||
if [[ $GUI_TARGET =~ ^x86_64.*$ ]]; then
|
||||
# currently only x86 appimage is supported
|
||||
mv ./target/$GUI_TARGET/release/bundle/appimage/*.AppImage ./artifacts/objects/
|
||||
fi
|
||||
mv ./target/$GUI_TARGET/release/bundle/rpm/*.rpm ./artifacts/objects/
|
||||
mv ./target/$GUI_TARGET/release/bundle/appimage/*.AppImage ./artifacts/objects/
|
||||
fi
|
||||
|
||||
mv ./artifacts/objects/* ./artifacts/
|
||||
rm -rf ./artifacts/objects/
|
||||
|
||||
- name: Archive artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: easytier-gui-${{ matrix.ARTIFACT_NAME }}
|
||||
path: |
|
||||
./artifacts/*
|
||||
|
||||
gui-result:
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && always()
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- pre_job
|
||||
- build-gui
|
||||
needs: [ pre_job, build-gui ]
|
||||
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
|
||||
steps:
|
||||
- name: Mark result as failed
|
||||
if: needs.build-gui.result != 'success'
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
sudo apt update
|
||||
sudo apt install -qq libwebkit2gtk-4.1-dev \
|
||||
build-essential \
|
||||
curl \
|
||||
wget \
|
||||
file \
|
||||
libgtk-3-dev \
|
||||
librsvg2-dev \
|
||||
libxdo-dev \
|
||||
libssl-dev \
|
||||
patchelf
|
||||
@@ -1,61 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# env needed:
|
||||
# - TARGET
|
||||
# - GUI_TARGET
|
||||
# - OS
|
||||
|
||||
# dependencies are only needed on ubuntu as that's the only place where
|
||||
# we make cross-compilation
|
||||
if [[ $OS =~ ^ubuntu.*$ ]]; then
|
||||
sudo apt-get update && sudo apt-get install -qq musl-tools libappindicator3-dev llvm clang
|
||||
# https://github.com/cross-tools/musl-cross/releases
|
||||
# if "musl" is a substring of TARGET, we assume that we are using musl
|
||||
MUSL_TARGET=$TARGET
|
||||
# if target is mips or mipsel, we should use soft-float version of musl
|
||||
if [[ $TARGET =~ ^mips.*$ || $TARGET =~ ^mipsel.*$ ]]; then
|
||||
MUSL_TARGET=${TARGET}sf
|
||||
elif [[ $TARGET =~ ^riscv64gc-.*$ ]]; then
|
||||
MUSL_TARGET=${TARGET/#riscv64gc-/riscv64-}
|
||||
fi
|
||||
if [[ $MUSL_TARGET =~ musl ]]; then
|
||||
mkdir -p ./musl_gcc
|
||||
wget --inet4-only -c https://github.com/cross-tools/musl-cross/releases/download/20250520/${MUSL_TARGET}.tar.xz -P ./musl_gcc/
|
||||
tar xf ./musl_gcc/${MUSL_TARGET}.tar.xz -C ./musl_gcc/
|
||||
sudo ln -sf $(pwd)/musl_gcc/${MUSL_TARGET}/bin/*gcc /usr/bin/
|
||||
sudo ln -sf $(pwd)/musl_gcc/${MUSL_TARGET}/include/ /usr/include/musl-cross
|
||||
sudo ln -sf $(pwd)/musl_gcc/${MUSL_TARGET}/${MUSL_TARGET}/sysroot/ ./musl_gcc/sysroot
|
||||
sudo chmod -R a+rwx ./musl_gcc
|
||||
fi
|
||||
fi
|
||||
|
||||
# see https://github.com/rust-lang/rustup/issues/3709
|
||||
rustup set auto-self-update disable
|
||||
rustup install 1.89
|
||||
rustup default 1.89
|
||||
|
||||
# mips/mipsel cannot add target from rustup, need compile by ourselves
|
||||
if [[ $OS =~ ^ubuntu.*$ && $TARGET =~ ^mips.*$ ]]; then
|
||||
cd "$PWD/musl_gcc/${MUSL_TARGET}/lib/gcc/${MUSL_TARGET}/15.1.0" || exit 255
|
||||
# for panic-abort
|
||||
cp libgcc_eh.a libunwind.a
|
||||
|
||||
# for mimalloc
|
||||
ar x libgcc.a _ctzsi2.o _clz.o _bswapsi2.o
|
||||
ar rcs libctz.a _ctzsi2.o _clz.o _bswapsi2.o
|
||||
|
||||
rustup toolchain install nightly-2025-09-01-x86_64-unknown-linux-gnu
|
||||
rustup component add rust-src --toolchain nightly-2025-09-01-x86_64-unknown-linux-gnu
|
||||
|
||||
# https://github.com/rust-lang/rust/issues/128808
|
||||
# remove it after Cargo or rustc fix this.
|
||||
RUST_LIB_SRC=$HOME/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/
|
||||
if [[ -f $RUST_LIB_SRC/library/Cargo.lock && ! -f $RUST_LIB_SRC/Cargo.lock ]]; then
|
||||
cp -f $RUST_LIB_SRC/library/Cargo.lock $RUST_LIB_SRC/Cargo.lock
|
||||
fi
|
||||
else
|
||||
rustup target add $TARGET
|
||||
if [[ $GUI_TARGET != '' ]]; then
|
||||
rustup target add $GUI_TARGET
|
||||
fi
|
||||
fi
|
||||
@@ -5,7 +5,12 @@ on:
|
||||
branches: ["develop", "main", "releases/**"]
|
||||
pull_request:
|
||||
branches: ["develop", "main"]
|
||||
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
@@ -18,6 +23,7 @@ jobs:
|
||||
pre_job:
|
||||
# continue-on-error: true # Uncomment once integration is finished
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
# Map a step output to a job output
|
||||
outputs:
|
||||
should_skip: ${{ steps.skip_check.outputs.should_skip == 'true' && !startsWith(github.ref_name, 'releases/') }}
|
||||
@@ -29,25 +35,30 @@ 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/workflows/install_rust.sh"]'
|
||||
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-gui/**", "tauri-plugin-vpnservice/**", ".github/workflows/mobile.yml", ".github/actions/**"]'
|
||||
build-mobile:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
fail-fast: true
|
||||
matrix:
|
||||
include:
|
||||
- TARGET: android
|
||||
OS: ubuntu-22.04
|
||||
ARTIFACT_NAME: android
|
||||
runs-on: ${{ matrix.OS }}
|
||||
- TARGET: aarch64-linux-android
|
||||
ARCH: aarch64
|
||||
- TARGET: armv7-linux-androideabi
|
||||
ARCH: armv7
|
||||
- TARGET: i686-linux-android
|
||||
ARCH: i686
|
||||
- TARGET: x86_64-linux-android
|
||||
ARCH: x86_64
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NAME: easytier
|
||||
TARGET: ${{ matrix.TARGET }}
|
||||
OS: ${{ matrix.OS }}
|
||||
ARCH: ${{ matrix.ARCH }}
|
||||
OSS_BUCKET: ${{ secrets.ALIYUN_OSS_BUCKET }}
|
||||
needs: pre_job
|
||||
if: needs.pre_job.outputs.should_skip != 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Set current ref as env variable
|
||||
run: |
|
||||
@@ -61,72 +72,41 @@ jobs:
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
with:
|
||||
cmdline-tools-version: 11076708
|
||||
packages: 'build-tools;34.0.0 ndk;26.0.10792818 tools platform-tools platforms;android-34 '
|
||||
cmdline-tools-version: 12.0
|
||||
packages: 'build-tools;34.0.0 ndk;26.0.10792818 platform-tools platforms;android-34 '
|
||||
|
||||
- name: Setup Android Environment
|
||||
run: |
|
||||
echo "$ANDROID_HOME/platform-tools" >> $GITHUB_PATH
|
||||
echo "$ANDROID_HOME/ndk/26.0.10792818/toolchains/llvm/prebuilt/linux-x86_64/bin" >> $GITHUB_PATH
|
||||
echo "NDK_HOME=$ANDROID_HOME/ndk/26.0.10792818/" > $GITHUB_ENV
|
||||
echo "NDK_HOME=$ANDROID_HOME/ndk/26.0.10792818/" >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- name: Prepare build environment
|
||||
uses: ./.github/actions/prepare-build
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: |
|
||||
pnpm -r install
|
||||
pnpm -r build
|
||||
target: ${{ matrix.TARGET }}
|
||||
gui: false
|
||||
pnpm: true
|
||||
pnpm-build-filter: ''
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
# The prefix cache key, this can be changed to start a new cache manually.
|
||||
# default: "v0-rust"
|
||||
prefix-key: ""
|
||||
shared-key: "gui-registry"
|
||||
cache-targets: "false"
|
||||
|
||||
- name: Install rust target
|
||||
run: |
|
||||
bash ./.github/workflows/install_rust.sh
|
||||
rustup target add aarch64-linux-android
|
||||
rustup target add armv7-linux-androideabi
|
||||
rustup target add i686-linux-android
|
||||
rustup target add x86_64-linux-android
|
||||
|
||||
- name: Setup protoc
|
||||
uses: arduino/setup-protoc@v3
|
||||
with:
|
||||
# GitHub repo token to use to avoid rate limiter
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Android
|
||||
- name: Build
|
||||
run: |
|
||||
cd easytier-gui
|
||||
pnpm tauri android build
|
||||
pnpm tauri android build --apk --target "$ARCH" --split-per-abi
|
||||
|
||||
- name: Compress
|
||||
- name: Collect artifact
|
||||
run: |
|
||||
mkdir -p ./artifacts/objects/
|
||||
mv easytier-gui/src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk ./artifacts/objects/
|
||||
mv easytier-gui/src-tauri/gen/android/app/build/outputs/apk/*/release/*.apk ./artifacts/objects/
|
||||
|
||||
if [[ $GITHUB_REF_TYPE =~ ^tag$ ]]; then
|
||||
TAG=$GITHUB_REF_NAME
|
||||
@@ -134,23 +114,21 @@ jobs:
|
||||
TAG=$GITHUB_SHA
|
||||
fi
|
||||
|
||||
mv ./artifacts/objects/* ./artifacts
|
||||
mv ./artifacts/objects/* ./artifacts/
|
||||
rm -rf ./artifacts/objects/
|
||||
|
||||
- name: Archive artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: easytier-gui-${{ matrix.ARTIFACT_NAME }}
|
||||
name: easytier-mobile-android-${{ matrix.ARCH }}
|
||||
path: |
|
||||
./artifacts/*
|
||||
|
||||
mobile-result:
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && always()
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- pre_job
|
||||
- build-mobile
|
||||
needs: [ pre_job, build-mobile ]
|
||||
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
|
||||
steps:
|
||||
- name: Mark result as failed
|
||||
if: needs.build-mobile.result != 'success'
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Nix Check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main", "develop"]
|
||||
paths:
|
||||
- "**/*.nix"
|
||||
- "flake.lock"
|
||||
- "rust-toolchain.toml"
|
||||
pull_request:
|
||||
branches: ["main", "develop"]
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths:
|
||||
- "**/*.nix"
|
||||
- "flake.lock"
|
||||
- "rust-toolchain.toml"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-full-shell:
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Nix
|
||||
uses: cachix/install-nix-action@v27
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
|
||||
- name: Magic Nix Cache
|
||||
uses: DeterminateSystems/magic-nix-cache-action@v6
|
||||
|
||||
- name: Warm up full devShell
|
||||
run: nix develop .#full --command true
|
||||
|
||||
- name: Cargo check in flake environment
|
||||
run: nix develop .#full --command cargo check
|
||||
|
||||
- name: Cargo build in flake environment
|
||||
run: nix develop .#full --command cargo build
|
||||
+95
-34
@@ -3,10 +3,18 @@ name: EasyTier OHOS
|
||||
on:
|
||||
push:
|
||||
branches: ["develop", "main", "releases/**"]
|
||||
tags:
|
||||
- 'v*'
|
||||
- '!*-pre'
|
||||
pull_request:
|
||||
branches: ["develop", "main"]
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
@@ -17,18 +25,29 @@ defaults:
|
||||
|
||||
jobs:
|
||||
cargo_fmt_check:
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: fmt check
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Prepare build environment
|
||||
uses: ./.github/actions/prepare-build
|
||||
with:
|
||||
gui: false
|
||||
pnpm: false
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
components: rustfmt
|
||||
|
||||
- name: Check formatting
|
||||
working-directory: ./easytier-contrib/easytier-ohrs
|
||||
run: |
|
||||
bash ../../.github/workflows/install_rust.sh
|
||||
rustup component add rustfmt
|
||||
cargo fmt --all -- --check
|
||||
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/
|
||||
@@ -41,55 +60,71 @@ jobs:
|
||||
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/workflows/install_rust.sh"]'
|
||||
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@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
sudo apt-get install -qq \
|
||||
build-essential \
|
||||
wget \
|
||||
unzip \
|
||||
git \
|
||||
pkg-config curl libgl1-mesa-dev expect
|
||||
sudo apt-get clean
|
||||
|
||||
- name: Count commits since last tag on upstream main
|
||||
- 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 upstream --tags --force
|
||||
git fetch --unshallow upstream main || git fetch upstream main
|
||||
git fetch --tags upstream --force
|
||||
|
||||
# 获取 upstream/main 最新提交
|
||||
git fetch upstream main
|
||||
# 读取 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}"
|
||||
|
||||
if [ -z "$LAST_TAG" ]; then
|
||||
# 语义版本比较
|
||||
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
|
||||
|
||||
echo "TAG_COMMIT_DIFF=$DIFF_COUNT"
|
||||
echo "TAG_COMMIT_DIFF=$DIFF_COUNT" >> $GITHUB_ENV
|
||||
|
||||
- name: Get easytier version
|
||||
run: |
|
||||
EASYTIER_CARGO_VERSION=$(cargo metadata --format-version 1 --no-deps --manifest-path easytier/Cargo.toml \
|
||||
| jq -r '.packages[0].version')
|
||||
EASYTIER_VERSION="${EASYTIER_CARGO_VERSION}-${TAG_COMMIT_DIFF}"
|
||||
echo "EASYTIER_VERSION=${EASYTIER_VERSION}" >> $GITHUB_ENV
|
||||
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: |
|
||||
@@ -115,6 +150,15 @@ jobs:
|
||||
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
|
||||
with:
|
||||
tool: ohrs
|
||||
|
||||
- name: Create clang wrapper script
|
||||
run: |
|
||||
sudo mkdir -p $OHOS_NDK_HOME/native/llvm
|
||||
@@ -128,38 +172,50 @@ jobs:
|
||||
EOF
|
||||
sudo chmod +x $OHOS_NDK_HOME/native/llvm/aarch64-unknown-linux-ohos-clang.sh
|
||||
|
||||
- name: Build
|
||||
- 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
|
||||
bash ../../.github/workflows/install_rust.sh
|
||||
source env.sh
|
||||
cargo install ohrs
|
||||
rustup target add aarch64-unknown-linux-ohos
|
||||
cargo update easytier
|
||||
ohrs doctor
|
||||
ohrs build --release --arch aarch
|
||||
ohrs artifact
|
||||
mv package.har easytier-ohrs.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
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: easytier-ohos
|
||||
path: |
|
||||
./easytier-contrib/easytier-ohrs/easytier-ohrs.har
|
||||
./easytier-contrib/easytier-ohrs/dist/arm64-v8a/libeasytier_ohrs.so
|
||||
retention-days: 5
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Publish To Center Ohpm
|
||||
if: github.event_name == 'push'
|
||||
working-directory: ./easytier-contrib/easytier-ohrs
|
||||
env:
|
||||
OHPM_PUBLISH_CODE: ${{ secrets.OHPM_PUBLISH_CODE }}
|
||||
OHPM_PRIVATE_KEY: ${{ secrets.OHPM_PRIVATE_KEY }}
|
||||
OHPM_KEY_PASSPHRASE: ${{ secrets.OHPM_KEY_PASSPHRASE }}
|
||||
if: ${{ env.OHPM_PUBLISH_CODE != '' && github.event_name == 'push' }}
|
||||
run: |
|
||||
ohpm config set publish_id "$OHPM_PUBLISH_CODE"
|
||||
ohpm config set publish_registry https://ohpm.openharmony.cn/ohpm
|
||||
@@ -176,10 +232,15 @@ jobs:
|
||||
ohpm publish easytier-ohrs.har
|
||||
|
||||
- name: Publish To Private Ohpm
|
||||
if: github.event_name == 'push'
|
||||
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
|
||||
fi
|
||||
curl --header "Content-Type: application/json" --request POST --data "{}" ${{ secrets.CODEARTS_WEBHOOKS }}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ on:
|
||||
version:
|
||||
description: 'Version for this release'
|
||||
type: string
|
||||
default: 'v2.5.0'
|
||||
default: 'v2.6.0'
|
||||
required: true
|
||||
make_latest:
|
||||
description: 'Mark this release as latest'
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Download Core Artifact
|
||||
uses: dawidd6/action-download-artifact@v11
|
||||
|
||||
+109
-68
@@ -2,12 +2,18 @@ name: EasyTier Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["develop", "main"]
|
||||
branches: [ "develop", "main" ]
|
||||
pull_request:
|
||||
branches: ["develop", "main"]
|
||||
branches: [ "develop", "main" ]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
# RUSTC_WRAPPER: "sccache"
|
||||
# SCCACHE_GHA_ENABLED: "true"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
@@ -28,19 +34,94 @@ 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/workflows/install_gui_dep.sh", ".github/workflows/install_rust.sh"]'
|
||||
test:
|
||||
runs-on: ubuntu-22.04
|
||||
needs: pre_job
|
||||
if: needs.pre_job.outputs.should_skip != 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", ".github/workflows/test.yml", ".github/actions/**"]'
|
||||
|
||||
- name: Setup protoc
|
||||
uses: arduino/setup-protoc@v3
|
||||
check:
|
||||
name: Run linters & check
|
||||
runs-on: ubuntu-latest
|
||||
needs: pre_job
|
||||
if: needs.pre_job.outputs.should_skip != 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Prepare build environment
|
||||
uses: ./.github/actions/prepare-build
|
||||
with:
|
||||
# GitHub repo token to use to avoid rate limiter
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
gui: true
|
||||
pnpm: true
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
components: rustfmt,clippy
|
||||
rustflags: ''
|
||||
|
||||
- uses: taiki-e/install-action@cargo-hack
|
||||
|
||||
- name: Check Cargo.lock is up to date
|
||||
run: |
|
||||
if ! cargo metadata --format-version 1 --locked --no-deps > /dev/null; then
|
||||
echo "::error::Cargo.lock is out of date. Run cargo generate-lockfile or cargo build locally, then commit Cargo.lock."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check formatting
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Check Clippy
|
||||
run: cargo clippy --all-targets --features full --all -- -D warnings
|
||||
|
||||
- name: Check features
|
||||
if: ${{ !cancelled() }}
|
||||
run: cargo hack check --package easytier --each-feature --exclude-features macos-ne --verbose
|
||||
|
||||
pre-test:
|
||||
name: Build test
|
||||
runs-on: ubuntu-latest
|
||||
needs: pre_job
|
||||
if: needs.pre_job.outputs.should_skip != 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Prepare build environment
|
||||
uses: ./.github/actions/prepare-build
|
||||
with:
|
||||
gui: true
|
||||
pnpm: true
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- uses: taiki-e/install-action@nextest
|
||||
|
||||
- name: Archive test
|
||||
run: cargo nextest archive --archive-file tests.tar.zst --package easytier --features full
|
||||
|
||||
- uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: tests
|
||||
path: tests.tar.zst
|
||||
retention-days: 1
|
||||
|
||||
test_matrix:
|
||||
name: Test (${{ matrix.name }})
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ pre_job, pre-test ]
|
||||
if: needs.pre_job.outputs.should_skip != 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: "easytier"
|
||||
opts: "-E 'not test(tests::three_node)' --test-threads 1 --no-fail-fast"
|
||||
|
||||
- name: "three_node"
|
||||
opts: "-E 'test(tests::three_node) and not test(subnet_proxy_three_node_test)' --test-threads 1 --no-fail-fast"
|
||||
|
||||
- name: "three_node::subnet_proxy_three_node_test"
|
||||
opts: "-E 'test(subnet_proxy_three_node_test)' --test-threads 1 --no-fail-fast"
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Setup tools for test
|
||||
run: sudo apt install bridge-utils
|
||||
@@ -53,63 +134,23 @@ jobs:
|
||||
sudo sysctl net.ipv6.conf.lo.disable_ipv6=0
|
||||
sudo ip addr add 2001:db8::2/64 dev lo
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: taiki-e/install-action@nextest
|
||||
|
||||
- name: Download tests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: |
|
||||
pnpm -r install
|
||||
pnpm -r --filter "./easytier-web/*" build
|
||||
|
||||
- name: Cargo cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo
|
||||
./target
|
||||
key: ${{ runner.os }}-cargo-test-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Install GUI dependencies (Used by clippy)
|
||||
run: |
|
||||
bash ./.github/workflows/install_gui_dep.sh
|
||||
bash ./.github/workflows/install_rust.sh
|
||||
rustup component add rustfmt
|
||||
rustup component add clippy
|
||||
|
||||
- name: Check formatting
|
||||
if: ${{ !cancelled() }}
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Check Clippy
|
||||
if: ${{ !cancelled() }}
|
||||
# NOTE: tauri need `dist` dir in build.rs
|
||||
run: |
|
||||
mkdir -p easytier-gui/dist
|
||||
cargo clippy --all-targets --all-features --all -- -D warnings
|
||||
name: tests
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
sudo prlimit --pid $$ --nofile=1048576:1048576
|
||||
sudo -E env "PATH=$PATH" cargo test --no-default-features --features=full --verbose -- --test-threads=1
|
||||
sudo chown -R $USER:$USER ./target
|
||||
sudo chown -R $USER:$USER ~/.cargo
|
||||
sudo -E env "PATH=$PATH" cargo nextest run --archive-file tests.tar.zst ${{ matrix.opts }}
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ pre_job, check, test_matrix ]
|
||||
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
|
||||
steps:
|
||||
- name: Mark result as failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
|
||||
+3
-3
@@ -26,7 +26,7 @@ Thank you for your interest in contributing to EasyTier! This document provides
|
||||
#### Required Tools
|
||||
- Node.js v21 or higher
|
||||
- pnpm v9 or higher
|
||||
- Rust toolchain (version 1.89)
|
||||
- Rust toolchain (version 1.95)
|
||||
- LLVM and Clang
|
||||
- Protoc (Protocol Buffers compiler)
|
||||
|
||||
@@ -79,8 +79,8 @@ sudo apt install -y bridge-utils
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
# Install Rust toolchain
|
||||
rustup install 1.89
|
||||
rustup default 1.89
|
||||
rustup install 1.95
|
||||
rustup default 1.95
|
||||
|
||||
# Install project dependencies
|
||||
pnpm -r install
|
||||
|
||||
+3
-3
@@ -34,7 +34,7 @@
|
||||
#### 必需工具
|
||||
- Node.js v21 或更高版本
|
||||
- pnpm v9 或更高版本
|
||||
- Rust 工具链(版本 1.89)
|
||||
- Rust 工具链(版本 1.95)
|
||||
- LLVM 和 Clang
|
||||
- Protoc(Protocol Buffers 编译器)
|
||||
|
||||
@@ -87,8 +87,8 @@ sudo apt install -y bridge-utils
|
||||
2. 安装依赖:
|
||||
```bash
|
||||
# 安装 Rust 工具链
|
||||
rustup install 1.89
|
||||
rustup default 1.89
|
||||
rustup install 1.95
|
||||
rustup default 1.95
|
||||
|
||||
# 安装项目依赖
|
||||
pnpm -r install
|
||||
|
||||
Generated
+1669
-1010
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,10 @@ exclude = [
|
||||
"easytier-contrib/easytier-ohrs", # it needs ohrs sdk
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
|
||||
[profile.dev]
|
||||
panic = "unwind"
|
||||
debug = 2
|
||||
|
||||
@@ -48,40 +48,43 @@
|
||||
|
||||
Choose the installation method that best suits your needs:
|
||||
|
||||
Linux (Recommended):
|
||||
```bash
|
||||
# 1. Download pre-built binary (Recommended, All platforms supported)
|
||||
# Visit https://github.com/EasyTier/EasyTier/releases
|
||||
curl -fsSL "https://github.com/EasyTier/EasyTier/blob/main/script/install.sh?raw=true" | sudo bash -s install
|
||||
```
|
||||
|
||||
# 2. Install via cargo (Latest development version)
|
||||
cargo install --git https://github.com/EasyTier/EasyTier.git easytier
|
||||
|
||||
# 3. Install via Docker
|
||||
# See https://easytier.cn/en/guide/installation.html#installation-methods
|
||||
|
||||
# 4. Linux Quick Install
|
||||
wget -O- https://raw.githubusercontent.com/EasyTier/EasyTier/main/script/install.sh | sudo bash -s install
|
||||
|
||||
# 5. MacOS via Homebrew
|
||||
Homebrew (MacOS/Linux):
|
||||
```bash
|
||||
brew tap brewforge/chinese
|
||||
brew install --cask easytier-gui
|
||||
|
||||
# 6. OpenWrt Luci Web UI
|
||||
# Visit https://github.com/EasyTier/luci-app-easytier
|
||||
|
||||
# 7. (Optional) Install shell completions:
|
||||
easytier-core --gen-autocomplete fish > ~/.config/fish/completions/easytier-core.fish
|
||||
easytier-cli gen-autocomplete fish > ~/.config/fish/completions/easytier-cli.fish
|
||||
|
||||
```
|
||||
|
||||
Windows (Recommended, run with administrator privileges):
|
||||
```powershell
|
||||
irm "https://github.com/EasyTier/EasyTier/blob/main/script/install.ps1?raw=true" | iex
|
||||
```
|
||||
|
||||
Install via cargo (Latest development version):
|
||||
```bash
|
||||
cargo install --git https://github.com/EasyTier/EasyTier.git easytier
|
||||
```
|
||||
|
||||
[Install pre-built binary](https://github.com/EasyTier/EasyTier/releases) (Recommended, All platforms supported)
|
||||
|
||||
[Install via Docker](https://easytier.cn/en/guide/installation.html#installation-methods)
|
||||
|
||||
[Install OpenWrt ipk package](https://github.com/EasyTier/luci-app-easytier)
|
||||
|
||||
Additional steps:
|
||||
|
||||
[One-Click Register Service](https://easytier.cn/en/guide/network/oneclick-install-as-service.html) (Automatically start when the system boots and run in the background)
|
||||
|
||||
### 🚀 Basic Usage
|
||||
|
||||
#### Quick Networking with Shared Nodes
|
||||
|
||||
EasyTier supports quick networking using shared public nodes. When you don't have a public IP, you can use the free shared nodes provided by the EasyTier community. Nodes will automatically attempt NAT traversal and establish P2P connections. When P2P fails, data will be relayed through shared nodes.
|
||||
|
||||
The currently deployed shared public node is `tcp://public.easytier.cn:11010`.
|
||||
|
||||
When using shared nodes, each node entering the network needs to provide the same `--network-name` and `--network-secret` parameters as the unique identifier of the network.
|
||||
|
||||
Taking two nodes as an example (Please use more complex network name to avoid conflicts):
|
||||
@@ -90,14 +93,14 @@ Taking two nodes as an example (Please use more complex network name to avoid co
|
||||
|
||||
```bash
|
||||
# Run with administrator privileges
|
||||
sudo easytier-core -d --network-name abc --network-secret abc -p tcp://public.easytier.cn:11010
|
||||
sudo easytier-core -d --network-name abc --network-secret abc -p tcp://<SharedNodeIP>:11010
|
||||
```
|
||||
|
||||
2. Run on Node B:
|
||||
|
||||
```bash
|
||||
# Run with administrator privileges
|
||||
sudo easytier-core -d --network-name abc --network-secret abc -p tcp://public.easytier.cn:11010
|
||||
sudo easytier-core -d --network-name abc --network-secret abc -p tcp://<SharedNodeIP>:11010
|
||||
```
|
||||
|
||||
After successful execution, you can check the network status using `easytier-cli`:
|
||||
@@ -105,9 +108,9 @@ After successful execution, you can check the network status using `easytier-cli
|
||||
```text
|
||||
| ipv4 | hostname | cost | lat_ms | loss_rate | rx_bytes | tx_bytes | tunnel_proto | nat_type | id | version |
|
||||
| ------------ | -------------- | ----- | ------ | --------- | -------- | -------- | ------------ | -------- | ---------- | --------------- |
|
||||
| 10.126.126.1 | abc-1 | Local | * | * | * | * | udp | FullCone | 439804259 | 2.5.0-70e69a38~ |
|
||||
| 10.126.126.2 | abc-2 | p2p | 3.452 | 0 | 17.33 kB | 20.42 kB | udp | FullCone | 390879727 | 2.5.0-70e69a38~ |
|
||||
| | PublicServer_a | p2p | 27.796 | 0.000 | 50.01 kB | 67.46 kB | tcp | Unknown | 3771642457 | 2.5.0-70e69a38~ |
|
||||
| 10.126.126.1 | abc-1 | Local | * | * | * | * | udp | FullCone | 439804259 | 2.6.0-70e69a38~ |
|
||||
| 10.126.126.2 | abc-2 | p2p | 3.452 | 0 | 17.33 kB | 20.42 kB | udp | FullCone | 390879727 | 2.6.0-70e69a38~ |
|
||||
| | PublicServer_a | p2p | 27.796 | 0.000 | 50.01 kB | 67.46 kB | tcp | Unknown | 3771642457 | 2.6.0-70e69a38~ |
|
||||
```
|
||||
|
||||
You can test connectivity between nodes:
|
||||
@@ -124,7 +127,7 @@ To improve availability, you can connect to multiple shared nodes simultaneously
|
||||
|
||||
```bash
|
||||
# Connect to multiple shared nodes
|
||||
sudo easytier-core -d --network-name abc --network-secret abc -p tcp://public.easytier.cn:11010 -p udp://public.easytier.cn:11010
|
||||
sudo easytier-core -d --network-name abc --network-secret abc -p tcp://<SharedNodeIP1>:11010 -p udp://<SharedNodeIP2>:11010
|
||||
```
|
||||
|
||||
Once your network is set up successfully, you can easily configure it to start automatically on system boot. Refer to the [One-Click Register Service guide](https://easytier.cn/en/guide/network/oneclick-install-as-service.html) for step-by-step instructions on registering EasyTier as a system service.
|
||||
|
||||
+32
-30
@@ -48,40 +48,42 @@
|
||||
|
||||
选择最适合您需求的安装方式:
|
||||
|
||||
Linux(推荐):
|
||||
```bash
|
||||
# 1. 下载预编译二进制文件(推荐,支持所有平台)
|
||||
# 访问 https://github.com/EasyTier/EasyTier/releases
|
||||
curl -fsSL "https://github.com/EasyTier/EasyTier/blob/main/script/install.sh?raw=true" | sudo bash -s install
|
||||
```
|
||||
|
||||
# 2. 通过 cargo 安装(最新开发版本)
|
||||
cargo install --git https://github.com/EasyTier/EasyTier.git easytier
|
||||
|
||||
# 3. 通过 Docker 安装
|
||||
# 参见 https://easytier.cn/guide/installation.html#%E5%AE%89%E8%A3%85%E6%96%B9%E5%BC%8F
|
||||
|
||||
# 4. Linux 快速安装
|
||||
wget -O- https://raw.githubusercontent.com/EasyTier/EasyTier/main/script/install.sh | sudo bash -s install
|
||||
|
||||
# 5. MacOS 通过 Homebrew 安装
|
||||
Homebrew(MacOS/Linux):
|
||||
```bash
|
||||
brew tap brewforge/chinese
|
||||
brew install --cask easytier-gui
|
||||
|
||||
# 6. OpenWrt Luci Web 界面
|
||||
# 访问 https://github.com/EasyTier/luci-app-easytier
|
||||
|
||||
# 7.(可选)安装 Shell 补全功能:
|
||||
# Fish 补全
|
||||
easytier-core --gen-autocomplete fish > ~/.config/fish/completions/easytier-core.fish
|
||||
easytier-cli gen-autocomplete fish > ~/.config/fish/completions/easytier-cli.fish
|
||||
|
||||
```
|
||||
|
||||
Windows(推荐,请以管理员权限运行):
|
||||
```powershell
|
||||
irm "https://github.com/EasyTier/EasyTier/blob/main/script/install.ps1?raw=true" | iex
|
||||
```
|
||||
|
||||
通过 cargo 安装(最新开发版本):
|
||||
```bash
|
||||
cargo install --git https://github.com/EasyTier/EasyTier.git easytier
|
||||
```
|
||||
|
||||
[下载预编译文件](https://github.com/EasyTier/EasyTier/releases)(推荐,支持所有平台)
|
||||
|
||||
[通过 Docker 安装](https://easytier.cn/guide/installation.html#%E5%AE%89%E8%A3%85%E6%96%B9%E5%BC%8F)
|
||||
|
||||
[安装 OpenWrt ipk 软件包](https://github.com/EasyTier/luci-app-easytier)
|
||||
|
||||
附加步骤:
|
||||
|
||||
[一键注册系统服务](https://easytier.cn/guide/network/oneclick-install-as-service.html)(系统启动时自动后台运行)
|
||||
|
||||
### 🚀 基本用法
|
||||
|
||||
#### 使用共享节点快速组网
|
||||
|
||||
EasyTier 支持使用共享公共节点快速组网。当您没有公网 IP 时,可以使用 EasyTier 社区提供的免费共享节点。节点会自动尝试 NAT 穿透并建立 P2P 连接。当 P2P 失败时,数据将通过共享节点中继。
|
||||
|
||||
当前部署的共享公共节点是 `tcp://public.easytier.cn:11010`。
|
||||
EasyTier 支持使用共享节点快速组网。当您没有公网 IP 时,可以使用公共共享节点。节点会自动尝试 NAT 穿透并建立 P2P 连接。当 P2P 失败时,数据将通过共享节点中继。
|
||||
|
||||
使用共享节点时,每个进入网络的节点需要提供相同的 `--network-name` 和 `--network-secret` 参数作为网络的唯一标识符。
|
||||
|
||||
@@ -91,14 +93,14 @@ EasyTier 支持使用共享公共节点快速组网。当您没有公网 IP 时
|
||||
|
||||
```bash
|
||||
# 以管理员权限运行
|
||||
sudo easytier-core -d --network-name abc --network-secret abc -p tcp://public.easytier.cn:11010
|
||||
sudo easytier-core -d --network-name abc --network-secret abc -p tcp://<共享节点IP>:11010
|
||||
```
|
||||
|
||||
2. 在节点 B 上运行:
|
||||
|
||||
```bash
|
||||
# 以管理员权限运行
|
||||
sudo easytier-core -d --network-name abc --network-secret abc -p tcp://public.easytier.cn:11010
|
||||
sudo easytier-core -d --network-name abc --network-secret abc -p tcp://<共享节点IP>:11010
|
||||
```
|
||||
|
||||
执行成功后,可以使用 `easytier-cli` 检查网络状态:
|
||||
@@ -106,9 +108,9 @@ sudo easytier-core -d --network-name abc --network-secret abc -p tcp://public.ea
|
||||
```text
|
||||
| ipv4 | hostname | cost | lat_ms | loss_rate | rx_bytes | tx_bytes | tunnel_proto | nat_type | id | version |
|
||||
| ------------ | -------------- | ----- | ------ | --------- | -------- | -------- | ------------ | -------- | ---------- | --------------- |
|
||||
| 10.126.126.1 | abc-1 | Local | * | * | * | * | udp | FullCone | 439804259 | 2.5.0-70e69a38~ |
|
||||
| 10.126.126.2 | abc-2 | p2p | 3.452 | 0 | 17.33 kB | 20.42 kB | udp | FullCone | 390879727 | 2.5.0-70e69a38~ |
|
||||
| | PublicServer_a | p2p | 27.796 | 0.000 | 50.01 kB | 67.46 kB | tcp | Unknown | 3771642457 | 2.5.0-70e69a38~ |
|
||||
| 10.126.126.1 | abc-1 | Local | * | * | * | * | udp | FullCone | 439804259 | 2.6.0-70e69a38~ |
|
||||
| 10.126.126.2 | abc-2 | p2p | 3.452 | 0 | 17.33 kB | 20.42 kB | udp | FullCone | 390879727 | 2.6.0-70e69a38~ |
|
||||
| | PublicServer_a | p2p | 27.796 | 0.000 | 50.01 kB | 67.46 kB | tcp | Unknown | 3771642457 | 2.6.0-70e69a38~ |
|
||||
```
|
||||
|
||||
您可以测试节点之间的连通性:
|
||||
@@ -125,7 +127,7 @@ ping 10.126.126.2
|
||||
|
||||
```bash
|
||||
# 连接多个共享节点
|
||||
sudo easytier-core -d --network-name abc --network-secret abc -p tcp://public.easytier.cn:11010 -p udp://public.easytier.cn:11010
|
||||
sudo easytier-core -d --network-name abc --network-secret abc -p tcp://<公共节点IP>:11010 -p udp://<公共节点IP>:11010
|
||||
```
|
||||
|
||||
#### 去中心化组网
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "easytier-android-jni"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
edition.workspace = true
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use easytier::proto::api::manage::{NetworkInstanceRunningInfo, NetworkInstanceRunningInfoMap};
|
||||
use jni::JNIEnv;
|
||||
use jni::objects::{JClass, JObjectArray, JString};
|
||||
use jni::sys::{jint, jstring};
|
||||
use jni::JNIEnv;
|
||||
use once_cell::sync::Lazy;
|
||||
use std::ffi::{CStr, CString};
|
||||
use std::ptr;
|
||||
@@ -15,7 +15,7 @@ pub struct KeyValuePair {
|
||||
}
|
||||
|
||||
// 声明外部 C 函数
|
||||
extern "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);
|
||||
@@ -68,7 +68,7 @@ fn throw_exception(env: &mut JNIEnv, message: &str) {
|
||||
}
|
||||
|
||||
/// 设置 TUN 文件描述符
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_setTunFd(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
@@ -87,17 +87,17 @@ pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_setTunFd(
|
||||
|
||||
unsafe {
|
||||
let result = set_tun_fd(inst_name_cstr.as_ptr(), fd);
|
||||
if result != 0 {
|
||||
if let Some(error) = get_last_error() {
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
if result != 0
|
||||
&& let Some(error) = get_last_error()
|
||||
{
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析配置
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_parseConfig(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
@@ -115,17 +115,17 @@ pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_parseConfig(
|
||||
|
||||
unsafe {
|
||||
let result = parse_config(config_cstr.as_ptr());
|
||||
if result != 0 {
|
||||
if let Some(error) = get_last_error() {
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
if result != 0
|
||||
&& let Some(error) = get_last_error()
|
||||
{
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// 运行网络实例
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_runNetworkInstance(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
@@ -143,17 +143,17 @@ pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_runNetworkInstance(
|
||||
|
||||
unsafe {
|
||||
let result = run_network_instance(config_cstr.as_ptr());
|
||||
if result != 0 {
|
||||
if let Some(error) = get_last_error() {
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
if result != 0
|
||||
&& let Some(error) = get_last_error()
|
||||
{
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// 保持网络实例
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_retainNetworkInstance(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
@@ -165,10 +165,10 @@ pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_retainNetworkInstance(
|
||||
if instance_names.is_null() {
|
||||
unsafe {
|
||||
let result = retain_network_instance(ptr::null(), 0);
|
||||
if result != 0 {
|
||||
if let Some(error) = get_last_error() {
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
if result != 0
|
||||
&& let Some(error) = get_last_error()
|
||||
{
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -187,10 +187,10 @@ pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_retainNetworkInstance(
|
||||
if array_length == 0 {
|
||||
unsafe {
|
||||
let result = retain_network_instance(ptr::null(), 0);
|
||||
if result != 0 {
|
||||
if let Some(error) = get_last_error() {
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
if result != 0
|
||||
&& let Some(error) = get_last_error()
|
||||
{
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -234,17 +234,17 @@ pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_retainNetworkInstance(
|
||||
|
||||
unsafe {
|
||||
let result = retain_network_instance(c_string_ptrs.as_ptr(), c_string_ptrs.len());
|
||||
if result != 0 {
|
||||
if let Some(error) = get_last_error() {
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
if result != 0
|
||||
&& let Some(error) = get_last_error()
|
||||
{
|
||||
throw_exception(&mut env, &error);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// 收集网络信息
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_collectNetworkInfos(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
@@ -304,7 +304,7 @@ pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_collectNetworkInfos(
|
||||
}
|
||||
|
||||
/// 获取最后的错误信息
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_getLastError(
|
||||
env: JNIEnv,
|
||||
_class: JClass,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "easytier-ffi"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
edition.workspace = true
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
@@ -30,7 +30,7 @@ fn set_error_msg(msg: &str) {
|
||||
|
||||
/// # Safety
|
||||
/// Set the tun fd
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn set_tun_fd(
|
||||
inst_name: *const std::ffi::c_char,
|
||||
fd: std::ffi::c_int,
|
||||
@@ -59,7 +59,7 @@ pub unsafe extern "C" fn set_tun_fd(
|
||||
|
||||
/// # Safety
|
||||
/// Get the last error message
|
||||
#[no_mangle]
|
||||
#[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() {
|
||||
@@ -74,7 +74,7 @@ pub unsafe extern "C" fn get_error_msg(out: *mut *const std::ffi::c_char) {
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn free_string(s: *const std::ffi::c_char) {
|
||||
if s.is_null() {
|
||||
return;
|
||||
@@ -86,7 +86,7 @@ pub extern "C" fn free_string(s: *const std::ffi::c_char) {
|
||||
|
||||
/// # Safety
|
||||
/// Parse the config
|
||||
#[no_mangle]
|
||||
#[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());
|
||||
@@ -105,7 +105,7 @@ pub unsafe extern "C" fn parse_config(cfg_str: *const std::ffi::c_char) -> std::
|
||||
|
||||
/// # Safety
|
||||
/// Run the network instance
|
||||
#[no_mangle]
|
||||
#[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());
|
||||
@@ -144,7 +144,7 @@ pub unsafe extern "C" fn run_network_instance(cfg_str: *const std::ffi::c_char)
|
||||
|
||||
/// # Safety
|
||||
/// Retain the network instance
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn retain_network_instance(
|
||||
inst_names: *const *const std::ffi::c_char,
|
||||
length: usize,
|
||||
@@ -188,7 +188,7 @@ pub unsafe extern "C" fn retain_network_instance(
|
||||
|
||||
/// # Safety
|
||||
/// Collect the network infos
|
||||
#[no_mangle]
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn collect_network_infos(
|
||||
infos: *mut KeyValuePair,
|
||||
max_length: usize,
|
||||
@@ -215,7 +215,7 @@ pub unsafe extern "C" fn collect_network_infos(
|
||||
if index >= max_length {
|
||||
break;
|
||||
}
|
||||
let Some(key) = INSTANCE_MANAGER.get_network_instance_name(instance_id) else {
|
||||
let Some(key) = INSTANCE_MANAGER.get_instance_name(instance_id) else {
|
||||
continue;
|
||||
};
|
||||
// convert value to json string
|
||||
@@ -228,7 +228,7 @@ pub unsafe extern "C" fn collect_network_infos(
|
||||
};
|
||||
|
||||
infos[index] = KeyValuePair {
|
||||
key: std::ffi::CString::new(key.clone()).unwrap().into_raw(),
|
||||
key: std::ffi::CString::new(key).unwrap().into_raw(),
|
||||
value: std::ffi::CString::new(value).unwrap().into_raw(),
|
||||
};
|
||||
index += 1;
|
||||
|
||||
@@ -1,43 +1,74 @@
|
||||
#!/data/adb/magisk/busybox sh
|
||||
MODDIR=${0%/*}
|
||||
MODULE_PROP="${MODDIR}/module.prop"
|
||||
IP_RULE_SCRIPT="${MODDIR}/hotspot_iprule.sh"
|
||||
|
||||
ET_STATUS=""
|
||||
REDIR_STATUS=""
|
||||
# 更新module.prop文件中的description
|
||||
IS_RUNNING=false
|
||||
|
||||
# 确保辅助脚本有执行权限
|
||||
chmod +x "${IP_RULE_SCRIPT}" 2>/dev/null
|
||||
|
||||
# 更新 module.prop 文件中的 description
|
||||
update_module_description() {
|
||||
local status_message=$1
|
||||
sed -i "/^description=/c\description=[状态]${status_message}" ${MODULE_PROP}
|
||||
# 检查 module.prop 文件存在且 description 发生变化了再写入
|
||||
if [ -f "${MODULE_PROP}" ]; then
|
||||
local current_desc=$(grep "^description=" "${MODULE_PROP}")
|
||||
local new_desc="description=[状态] ${status_message}"
|
||||
if [ "${current_desc}" != "${new_desc}" ]; then
|
||||
sed -i "s#^description=.*#${new_desc}#" "${MODULE_PROP}"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
# 判断程序启动状态
|
||||
if [ -f "${MODDIR}/disable" ]; then
|
||||
ET_STATUS="已关闭"
|
||||
elif pgrep -f 'easytier-core' >/dev/null; then
|
||||
if [ -f "${MODDIR}/config/command_args"]; then
|
||||
ET_STATUS="主程序已开启(启动参数模式)"
|
||||
IS_RUNNING=false
|
||||
ET_STATUS="主程序已关闭"
|
||||
|
||||
elif pgrep -f "${MODDIR}/easytier-core" >/dev/null; then
|
||||
IS_RUNNING=true
|
||||
if [ -f "${MODDIR}/config/command_args" ]; then
|
||||
ET_STATUS="主程序正在运行(启动参数模式)"
|
||||
else
|
||||
ET_STATUS="主程序已开启(配置文件模式)"
|
||||
ET_STATUS="主程序正在运行(配置文件模式)"
|
||||
fi
|
||||
|
||||
elif [ -z "$ET_STATUS" ]; then
|
||||
# 既没 disable 也没运行,说明是异常停止或未启动
|
||||
ET_STATUS="主程序启动失败或未运行"
|
||||
fi
|
||||
|
||||
#ET_STATUS不存在说明开启模块未正常运行,不修改状态
|
||||
if [ -n "$ET_STATUS" ]; then
|
||||
if [ -f "${MODDIR}/enable_IP_rule" ]; then
|
||||
rm -f "${MODDIR}/enable_IP_rule"
|
||||
${MODDIR}/hotspot_iprule.sh del
|
||||
REDIR_STATUS="转发已禁用"
|
||||
echo "热点子网转发已禁用"
|
||||
echo "[ET-NAT] IP rule disabled." >> "${MODDIR}/log.log"
|
||||
else
|
||||
touch "${MODDIR}/enable_IP_rule"
|
||||
${MODDIR}/hotspot_iprule.sh del
|
||||
${MODDIR}/hotspot_iprule.sh add_once
|
||||
REDIR_STATUS="转发已激活"
|
||||
echo "热点子网转发已激活,热点开启后将自动将热点加入转发网络(要求已配置本地网络cidr=参数)。转发规则将随着热点开关而自动开关。该状态将保持到转发被禁用为止。"
|
||||
echo "[ET-NAT] IP rule enabled." >> "${MODDIR}/log.log"
|
||||
fi
|
||||
update_module_description "${ET_STATUS} | ${REDIR_STATUS}"
|
||||
# 无论主程序是否运行,都允许切换“开关文件”的状态,以便下次生效
|
||||
if [ -f "${MODDIR}/enable_IP_rule" ]; then
|
||||
rm -f "${MODDIR}/enable_IP_rule"
|
||||
|
||||
"${IP_RULE_SCRIPT}" del >/dev/null 2>&1
|
||||
|
||||
REDIR_STATUS="转发已禁用"
|
||||
echo "热点子网转发已禁用"
|
||||
echo "[ET-NAT] Action: IP rule disabled." >> "${MODDIR}/log.log"
|
||||
else
|
||||
echo "主程序未正常启动,请先检查配置文件"
|
||||
touch "${MODDIR}/enable_IP_rule"
|
||||
|
||||
if [ "$IS_RUNNING" = true ]; then
|
||||
"${IP_RULE_SCRIPT}" del >/dev/null 2>&1
|
||||
"${IP_RULE_SCRIPT}" add_once
|
||||
echo "转发规则将立即生效,无需重启"
|
||||
else
|
||||
echo "主程序未运行,转发规则将在下次启动时生效"
|
||||
fi
|
||||
|
||||
REDIR_STATUS="转发已激活"
|
||||
echo "----------------------------------"
|
||||
echo "热点子网转发已激活"
|
||||
echo "热点开启后将自动将热点加入转发网络"
|
||||
echo "需要在配置中提前配置好 cidr 参数"
|
||||
echo "----------------------------------"
|
||||
echo "[ET-NAT] Action: IP rule enabled." >> "${MODDIR}/log.log"
|
||||
fi
|
||||
|
||||
sync
|
||||
update_module_description "${ET_STATUS}| ${REDIR_STATUS}"
|
||||
@@ -1,9 +1,19 @@
|
||||
ui_print '安装完成'
|
||||
ui_print '当前架构为' + $ARCH
|
||||
ui_print '当前系统版本为' + $API
|
||||
ui_print '安装目录为: /data/adb/modules/easytier_magisk'
|
||||
ui_print '配置文件位置: /data/adb/modules/easytier_magisk/config/config.toml'
|
||||
ui_print '如果需要自定义启动参数,可将 /data/adb/modules/easytier_magisk/config/command_args_sample 重命名为 command_args,并修改其中内容,使用自定义启动参数时会忽略配置文件'
|
||||
ui_print '修改配置文件后在magisk app禁用应用再启动即可生效'
|
||||
ui_print '点击操作按钮可启动/关闭热点子网转发,配合easytier的子网代理功能实现手机热点访问easytier网络'
|
||||
ui_print '记得重启'
|
||||
SKIPMOUNT=false
|
||||
PROPFILE=true
|
||||
POSTFSDATA=true
|
||||
LATESTARTSERVICE=true
|
||||
|
||||
set_perm_recursive $MODPATH 0 0 0777 0777
|
||||
|
||||
ui_print "系统架构为:$ARCH"
|
||||
ui_print "系统 SDK 版本:$API"
|
||||
ui_print "EasyTier 安装位置:/data/adb/modules/easytier_magisk"
|
||||
ui_print "配置文件位置:/data/adb/modules/easytier_magisk/config/config.toml"
|
||||
ui_print "如需使用启动参数模式,请将 /data/adb/modules/easytier_magisk/config/command_args_sample 重命名为 command_args,并修改其中的内容"
|
||||
ui_print "config 目录中存在 command_args 文件时,模块会自动忽略 config.toml 文件"
|
||||
ui_print "----------------------------------"
|
||||
ui_print "注意!启动参数文件中不能存在 \" 和 ',配置文件则没有这个限制"
|
||||
ui_print "----------------------------------"
|
||||
ui_print "修改配置后无需重启设备,在 Magisk 中禁用 EasyTier 模块,等待 10 秒后重新启用即可让新配置生效"
|
||||
ui_print "点击 Magisk 中模块左下角的“操作”按钮可以禁用或激活热点子网转发,使用该功能前需要在配置中提前配置好 cidr 参数"
|
||||
ui_print "模块安装完成,重启设备生效"
|
||||
@@ -2,64 +2,111 @@
|
||||
|
||||
MODDIR=${0%/*}
|
||||
CONFIG_FILE="${MODDIR}/config/config.toml"
|
||||
COMMAND_ARGS="${MODDIR}/config/command_args"
|
||||
LOG_FILE="${MODDIR}/log.log"
|
||||
MODULE_PROP="${MODDIR}/module.prop"
|
||||
EASYTIER="${MODDIR}/easytier-core"
|
||||
|
||||
# 处理获取到的设备型号中可能出现的空格
|
||||
BRAND=$(getprop ro.product.brand | tr ' ' '-')
|
||||
MODEL=$(getprop ro.product.model | tr ' ' '-')
|
||||
DEVICE_HOSTNAME="${BRAND}-${MODEL}"
|
||||
REDIR_STATUS=""
|
||||
|
||||
# 更新module.prop文件中的description
|
||||
# 更新 module.prop 文件中的 description
|
||||
update_module_description() {
|
||||
local status_message=$1
|
||||
sed -i "/^description=/c\description=[状态]${status_message}" ${MODULE_PROP}
|
||||
# 检查 module.prop 文件存在且 description 发生变化了再写入
|
||||
if [ -f "${MODULE_PROP}" ]; then
|
||||
local current_desc=$(grep "^description=" "${MODULE_PROP}")
|
||||
local new_desc="description=[状态] ${status_message}"
|
||||
if [ "${current_desc}" != "${new_desc}" ]; then
|
||||
sed -i "s#^description=.*#${new_desc}#" "${MODULE_PROP}"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
if [ -f "${MODDIR}/enable_IP_rule" ]; then
|
||||
REDIR_STATUS="转发已激活"
|
||||
else
|
||||
REDIR_STATUS="转发已禁用"
|
||||
fi
|
||||
|
||||
# 检查并初始化 TUN 设备
|
||||
if [ ! -e /dev/net/tun ]; then
|
||||
if [ ! -d /dev/net ]; then
|
||||
mkdir -p /dev/net
|
||||
fi
|
||||
|
||||
|
||||
ln -s /dev/tun /dev/net/tun
|
||||
fi
|
||||
|
||||
while true; do
|
||||
if ls $MODDIR | grep -q "disable"; then
|
||||
update_module_description "关闭中 | ${REDIR_STATUS}"
|
||||
if pgrep -f 'easytier-core' >/dev/null; then
|
||||
echo "开关控制$(date "+%Y-%m-%d %H:%M:%S") 进程已存在,正在关闭 ..."
|
||||
pkill easytier-core # 关闭进程
|
||||
fi
|
||||
# 获取子网转发激活状态
|
||||
if [ -f "${MODDIR}/enable_IP_rule" ]; then
|
||||
REDIR_STATUS="转发已激活"
|
||||
else
|
||||
if ! pgrep -f 'easytier-core' >/dev/null; then
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
update_module_description "config.toml不存在"
|
||||
sleep 3s
|
||||
continue
|
||||
fi
|
||||
REDIR_STATUS="转发已禁用"
|
||||
fi
|
||||
|
||||
# 如果 config 目录下存在 command_args 文件,则读取其中的内容作为启动参数
|
||||
if [ -f "${MODDIR}/config/command_args" ]; then
|
||||
TZ=Asia/Shanghai ${EASYTIER} $(cat ${MODDIR}/config/command_args) --hostname "$(getprop ro.product.brand)-$(getprop ro.product.model)" > ${LOG_FILE} &
|
||||
sleep 5s # 等待easytier-core启动完成
|
||||
update_module_description "主程序已开启(启动参数模式) | ${REDIR_STATUS}"
|
||||
else
|
||||
TZ=Asia/Shanghai ${EASYTIER} -c ${CONFIG_FILE} --hostname "$(getprop ro.product.brand)-$(getprop ro.product.model)" > ${LOG_FILE} &
|
||||
sleep 5s # 等待easytier-core启动完成
|
||||
update_module_description "主程序已开启(配置文件模式) | ${REDIR_STATUS}"
|
||||
fi
|
||||
ip rule add from all lookup main
|
||||
if ! pgrep -f 'easytier-core' >/dev/null; then
|
||||
update_module_descriptio "主程序启动失败,请检查配置文件"
|
||||
fi
|
||||
else
|
||||
echo "开关控制$(date "+%Y-%m-%d %H:%M:%S") 进程已存在"
|
||||
# 检查模块是否被禁用
|
||||
if [ -f "${MODDIR}/disable" ]; then
|
||||
update_module_description "主程序已关闭 | ${REDIR_STATUS}"
|
||||
if pgrep -f "${EASYTIER}" >/dev/null; then
|
||||
echo "开关控制 $(date "+%Y-%m-%d %H:%M:%S") 进程已存在,正在关闭"
|
||||
pkill -f "${EASYTIER}"
|
||||
fi
|
||||
sleep 10s
|
||||
continue
|
||||
fi
|
||||
|
||||
sleep 3s # 暂停3秒后再次执行循环
|
||||
done
|
||||
# 检查进程是否已经在运行
|
||||
if pgrep -f "${EASYTIER}" >/dev/null; then
|
||||
sleep 10s
|
||||
continue
|
||||
fi
|
||||
|
||||
# 检查配置文件是否存在
|
||||
if [ ! -f "${CONFIG_FILE}" ] && [ ! -f "${COMMAND_ARGS}" ]; then
|
||||
update_module_description "缺少配置文件或启动参数文件"
|
||||
sleep 10s
|
||||
continue
|
||||
fi
|
||||
|
||||
# 如果 config 目录下存在 command_args 文件,则读取其中的内容作为启动参数
|
||||
if [ -f "${COMMAND_ARGS}" ]; then
|
||||
# 启动参数模式
|
||||
CMD_CONTENT=$(tr '\r\n' ' ' < "${COMMAND_ARGS}")
|
||||
|
||||
if echo "${CMD_CONTENT}" | grep -q "\-\-hostname"; then
|
||||
FINAL_ARGS="${CMD_CONTENT}"
|
||||
else
|
||||
FINAL_ARGS="${CMD_CONTENT} --hostname ${DEVICE_HOSTNAME}"
|
||||
fi
|
||||
|
||||
TZ=Asia/Shanghai "${EASYTIER}" ${FINAL_ARGS} > "${LOG_FILE}" 2>&1 &
|
||||
STR_MODE="启动参数模式"
|
||||
|
||||
# 否则读取 config.toml 的内容作为启动参数
|
||||
else
|
||||
# 配置文件模式
|
||||
if grep -q "^[[:space:]]*hostname[[:space:]]*=" "${CONFIG_FILE}"; then
|
||||
TZ=Asia/Shanghai "${EASYTIER}" -c "${CONFIG_FILE}" > "${LOG_FILE}" 2>&1 &
|
||||
else
|
||||
TZ=Asia/Shanghai "${EASYTIER}" -c "${CONFIG_FILE}" --hostname "${DEVICE_HOSTNAME}" > "${LOG_FILE}" 2>&1 &
|
||||
fi
|
||||
|
||||
STR_MODE="配置文件模式"
|
||||
fi
|
||||
|
||||
# 等待进程启动
|
||||
sleep 5s
|
||||
|
||||
# 启动后的扫尾工作
|
||||
if pgrep -f "${EASYTIER}" >/dev/null; then
|
||||
|
||||
if ! ip rule show | grep -q "lookup main"; then
|
||||
ip rule add from all lookup main
|
||||
fi
|
||||
|
||||
update_module_description "主程序正在运行(${STR_MODE})| ${REDIR_STATUS}"
|
||||
else
|
||||
update_module_description "主程序启动失败,请检查配置文件或启动参数"
|
||||
fi
|
||||
|
||||
sleep 10s
|
||||
done
|
||||
@@ -1,6 +1,6 @@
|
||||
id=easytier_magisk
|
||||
name=EasyTier_Magisk
|
||||
version=v2.5.0
|
||||
version=v2.6.0
|
||||
versionCode=1
|
||||
author=EasyTier
|
||||
description=easytier magisk module @EasyTier(https://github.com/EasyTier/EasyTier)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
MODDIR=${0%/*}
|
||||
pkill easytier-core # 结束 easytier-core 进程
|
||||
rm -rf $MODDIR/*
|
||||
pkill -f "${MODDIR}/easytier-core"
|
||||
|
||||
# 使用 ${MODDIR:?} 确保变量非空,避免执行 rm -rf /*
|
||||
rm -rf "${MODDIR:?}/"*
|
||||
+434
-46
@@ -38,6 +38,20 @@ dependencies = [
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes-gcm"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"aes",
|
||||
"cipher",
|
||||
"ctr",
|
||||
"ghash",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.3"
|
||||
@@ -133,6 +147,12 @@ version = "1.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "async-recursion"
|
||||
version = "1.1.1"
|
||||
@@ -202,6 +222,12 @@ version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "atomic_refcell"
|
||||
version = "0.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41e67cd8309bbd06cd603a9e693a784ac2e5d1e955f11286e355089fcab3047c"
|
||||
|
||||
[[package]]
|
||||
name = "auto_impl"
|
||||
version = "1.3.0"
|
||||
@@ -254,14 +280,14 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.71.1"
|
||||
version = "0.72.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3"
|
||||
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools 0.13.0",
|
||||
"itertools 0.11.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
@@ -540,6 +566,16 @@ dependencies = [
|
||||
"clap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_complete_nushell"
|
||||
version = "4.5.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "685bc86fd34b7467e0532a4f8435ab107960d69a243785ef0275e571b35b641a"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.5.47"
|
||||
@@ -598,6 +634,15 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
@@ -746,6 +791,15 @@ version = "0.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2"
|
||||
|
||||
[[package]]
|
||||
name = "ctr"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek"
|
||||
version = "4.1.3"
|
||||
@@ -894,6 +948,17 @@ dependencies = [
|
||||
"powerfmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derivative"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
@@ -936,6 +1001,29 @@ dependencies = [
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
|
||||
dependencies = [
|
||||
"derive_more-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more-impl"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
|
||||
dependencies = [
|
||||
"convert_case 0.10.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"syn 2.0.106",
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -995,7 +1083,7 @@ checksum = "7454e41ff9012c00d53cf7f475c5e3afa3b91b7c90568495495e8d9bf47a1055"
|
||||
|
||||
[[package]]
|
||||
name = "easytier"
|
||||
version = "2.4.5"
|
||||
version = "2.6.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
@@ -1004,6 +1092,7 @@ dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
"atomic-shim",
|
||||
"atomic_refcell",
|
||||
"auto_impl",
|
||||
"base64 0.22.1",
|
||||
"bitflags 2.9.4",
|
||||
@@ -1011,16 +1100,23 @@ dependencies = [
|
||||
"bytecodec",
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"chrono",
|
||||
"cidr",
|
||||
"clap",
|
||||
"clap_complete",
|
||||
"clap_complete_nushell",
|
||||
"crossbeam",
|
||||
"dashmap",
|
||||
"dbus",
|
||||
"derivative",
|
||||
"derive_builder",
|
||||
"derive_more",
|
||||
"easytier-rpc-build",
|
||||
"encoding",
|
||||
"flume",
|
||||
"forwarded-header-value",
|
||||
"futures",
|
||||
"gethostname",
|
||||
"git-version",
|
||||
@@ -1036,6 +1132,8 @@ dependencies = [
|
||||
"humansize",
|
||||
"humantime-serde",
|
||||
"idna",
|
||||
"indoc",
|
||||
"itertools 0.14.0",
|
||||
"kcp-sys",
|
||||
"machine-uid",
|
||||
"multimap",
|
||||
@@ -1046,7 +1144,9 @@ dependencies = [
|
||||
"network-interface",
|
||||
"nix 0.29.0",
|
||||
"once_cell",
|
||||
"ordered_hash_map",
|
||||
"parking_lot",
|
||||
"paste",
|
||||
"percent-encoding",
|
||||
"petgraph 0.8.2",
|
||||
"pin-project-lite",
|
||||
@@ -1056,8 +1156,11 @@ dependencies = [
|
||||
"prost-build",
|
||||
"prost-reflect",
|
||||
"prost-reflect-build",
|
||||
"prost-types",
|
||||
"prost-wkt",
|
||||
"prost-wkt-build",
|
||||
"prost-wkt-types",
|
||||
"quinn",
|
||||
"quinn-plaintext",
|
||||
"rand 0.8.5",
|
||||
"rcgen",
|
||||
"regex",
|
||||
@@ -1073,10 +1176,13 @@ dependencies = [
|
||||
"sha2",
|
||||
"shellexpand",
|
||||
"smoltcp",
|
||||
"snow",
|
||||
"socket2 0.5.10",
|
||||
"strum",
|
||||
"stun_codec",
|
||||
"sys-locale",
|
||||
"tabled",
|
||||
"terminal_size",
|
||||
"thiserror 1.0.69",
|
||||
"thunk-rs",
|
||||
"time",
|
||||
@@ -1091,16 +1197,19 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"tun-easytier",
|
||||
"unicode-width 0.1.11",
|
||||
"url",
|
||||
"uuid",
|
||||
"version-compare",
|
||||
"which 7.0.3",
|
||||
"wildmatch",
|
||||
"winapi",
|
||||
"windivert",
|
||||
"windows 0.52.0",
|
||||
"windows-service",
|
||||
"windows-sys 0.52.0",
|
||||
"winreg 0.52.0",
|
||||
"x25519-dalek",
|
||||
"zerocopy 0.7.35",
|
||||
"zip",
|
||||
"zstd",
|
||||
@@ -1126,8 +1235,6 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "easytier-rpc-build"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24829168c28f6a448f57d18116c255dcbd2b8c25e76dbc60f6cd16d68ad2cf07"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"prost-build",
|
||||
@@ -1253,6 +1360,17 @@ version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "erased-serde"
|
||||
version = "0.4.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
"typeid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
@@ -1264,10 +1382,19 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastbloom"
|
||||
version = "0.14.0"
|
||||
name = "etherparse"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "18c1ddb9231d8554c2d6bdf4cfaabf0c59251658c68b6c95cd52dd0c513a912a"
|
||||
checksum = "827292ea592108849932ad8e30218f8b1f21c0dfd0696698a18b5d0aed62d990"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastbloom"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7f34442dbe69c60fe8eaf58a8cafff81a1f278816d8ab4db255b3bef4ac3c4"
|
||||
dependencies = [
|
||||
"getrandom 0.3.3",
|
||||
"libm",
|
||||
@@ -1280,6 +1407,9 @@ name = "fastrand"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||
dependencies = [
|
||||
"getrandom 0.2.16",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
@@ -1310,6 +1440,18 @@ dependencies = [
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flume"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"spin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
@@ -1346,6 +1488,16 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "forwarded-header-value"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9"
|
||||
dependencies = [
|
||||
"nonempty",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.31"
|
||||
@@ -1496,6 +1648,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ghash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
|
||||
dependencies = [
|
||||
"opaque-debug",
|
||||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gimli"
|
||||
version = "0.31.1"
|
||||
@@ -1605,9 +1767,9 @@ checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d"
|
||||
|
||||
[[package]]
|
||||
name = "heapless"
|
||||
version = "0.9.1"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1edcd5a338e64688fbdcb7531a846cfd3476a54784dcb918a0844682bc7ada5"
|
||||
checksum = "2af2455f757db2b292a9b1768c4b70186d443bcb3b316252d6b540aec1cd89ed"
|
||||
dependencies = [
|
||||
"hash32",
|
||||
"stable_deref_trait",
|
||||
@@ -2064,6 +2226,15 @@ dependencies = [
|
||||
"hashbrown 0.16.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indoc"
|
||||
version = "2.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
@@ -2073,6 +2244,15 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inventory"
|
||||
version = "0.3.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "io-uring"
|
||||
version = "0.7.10"
|
||||
@@ -2161,15 +2341,6 @@ dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
@@ -2230,7 +2401,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "kcp-sys"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/EasyTier/kcp-sys?rev=71eff18c573a4a71bf99c7fabc6a8b9f211c84c1#71eff18c573a4a71bf99c7fabc6a8b9f211c84c1"
|
||||
source = "git+https://github.com/EasyTier/kcp-sys?rev=94964794caaed5d388463137da59b97499619e5f#94964794caaed5d388463137da59b97499619e5f"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"auto_impl",
|
||||
@@ -2503,7 +2674,7 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b27250baa967a15214e57384dd6228c59afbccb15ab8f97207c9758917544bf5"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"convert_case 0.8.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"semver",
|
||||
@@ -2516,7 +2687,7 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c844efa85d53b5adc3b326520f3a108c3a737b7534ee10d406f81884e7e71b3c"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"convert_case 0.8.0",
|
||||
"ctor",
|
||||
"napi-derive-backend-ohos",
|
||||
"proc-macro2",
|
||||
@@ -2564,7 +2735,7 @@ dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe",
|
||||
"openssl-probe 0.1.6",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework 2.11.1",
|
||||
@@ -2689,6 +2860,12 @@ dependencies = [
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nonempty"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7"
|
||||
|
||||
[[package]]
|
||||
name = "normpath"
|
||||
version = "1.5.0"
|
||||
@@ -2810,6 +2987,12 @@ version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.109"
|
||||
@@ -2822,6 +3005,15 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered_hash_map"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6c699f8a30f345785be969deed7eee4c73a5de58c7faf61d6a3251ef798ff61"
|
||||
dependencies = [
|
||||
"hashbrown 0.15.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "papergrid"
|
||||
version = "0.12.0"
|
||||
@@ -2874,12 +3066,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "3.0.5"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3"
|
||||
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3045,6 +3237,18 @@ dependencies = [
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.11.1"
|
||||
@@ -3251,6 +3455,52 @@ dependencies = [
|
||||
"prost",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost-wkt"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "497e1e938f0c09ef9cabe1d49437b4016e03e8f82fbbe5d1c62a9b61b9decae1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"inventory",
|
||||
"prost",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"typetag",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost-wkt-build"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07b8bf115b70a7aa5af1fd5d6e9418492e9ccb6e4785e858c938e28d132a884b"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"prost",
|
||||
"prost-build",
|
||||
"prost-types",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost-wkt-types"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8cdde6df0a98311c839392ca2f2f0bcecd545f86a62b4e3c6a49c336e970fe5"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"prost",
|
||||
"prost-build",
|
||||
"prost-types",
|
||||
"prost-wkt",
|
||||
"prost-wkt-build",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.3"
|
||||
@@ -3281,10 +3531,22 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.13"
|
||||
name = "quinn-plaintext"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
|
||||
checksum = "f3e617feaeb6493018fa35fc47ae8b630ac8903d8159e9e747018841b99bad3d"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"quinn-proto",
|
||||
"seahash",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"fastbloom",
|
||||
@@ -3652,14 +3914,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.1"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3"
|
||||
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"openssl-probe 0.2.1",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework 3.5.0",
|
||||
"security-framework 3.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3683,9 +3945,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.6.1"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be59af91596cac372a6942530653ad0c3a246cdd491aaa9dcaee47f88d67d5a0"
|
||||
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
@@ -3696,10 +3958,10 @@ dependencies = [
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki",
|
||||
"security-framework 3.5.0",
|
||||
"security-framework 3.5.1",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3761,6 +4023,12 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "seahash"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "2.11.1"
|
||||
@@ -3776,9 +4044,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.5.0"
|
||||
version = "3.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc198e42d9b7510827939c9a15f5062a0c913f3371d765977e586d2fe6c16f4a"
|
||||
checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"core-foundation 0.10.1",
|
||||
@@ -3956,6 +4224,12 @@ version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe"
|
||||
|
||||
[[package]]
|
||||
name = "simdutf8"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.1"
|
||||
@@ -3987,6 +4261,23 @@ dependencies = [
|
||||
"managed",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "snow"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "599b506ccc4aff8cf7844bc42cf783009a434c1e26c964432560fb6d6ad02d82"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"blake2",
|
||||
"chacha20poly1305",
|
||||
"curve25519-dalek",
|
||||
"getrandom 0.3.3",
|
||||
"ring",
|
||||
"rustc_version",
|
||||
"sha2",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.5.10"
|
||||
@@ -4007,6 +4298,15 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.0"
|
||||
@@ -4019,6 +4319,27 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.27.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
|
||||
dependencies = [
|
||||
"strum_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.27.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stun_codec"
|
||||
version = "0.3.5"
|
||||
@@ -4369,9 +4690,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-websockets"
|
||||
version = "0.8.3"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "842e11addde61da7c37ef205cd625ebcd7b607076ea62e4698f06bfd5fd01a03"
|
||||
checksum = "dad543404f98bfc969aeb71994105c592acfc6c43323fddcd016bb208d1c65cb"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
@@ -4382,10 +4703,11 @@ dependencies = [
|
||||
"httparse",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"simdutf8",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"webpki-roots 0.26.11",
|
||||
"webpki-roots 1.0.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4617,12 +4939,42 @@ dependencies = [
|
||||
"wintun",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
|
||||
|
||||
[[package]]
|
||||
name = "typetag"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be2212c8a9b9bcfca32024de14998494cf9a5dfa59ea1b829de98bac374b86bf"
|
||||
dependencies = [
|
||||
"erased-serde",
|
||||
"inventory",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"typetag-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typetag-impl"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27a7a9b72ba121f6f1f6c3632b85604cac41aedb5ddc70accbebb6cac83de846"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.8.1"
|
||||
@@ -4653,6 +5005,12 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.5.1"
|
||||
@@ -4895,9 +5253,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.2"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e4ffd8df1c57e87c325000a3d6ef93db75279dc3a231125aac571650f22b12a"
|
||||
checksum = "36a29fc0408b113f68cf32637857ab740edfafdf460c326cd2afaa2d84cc05dc"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
@@ -4987,6 +5345,36 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windivert"
|
||||
version = "0.6.0"
|
||||
source = "git+https://github.com/EasyTier/windivert-rust.git?rev=adcc56d1550f7b5377ec2b3429f413ee24a77375#adcc56d1550f7b5377ec2b3429f413ee24a77375"
|
||||
dependencies = [
|
||||
"etherparse",
|
||||
"thiserror 1.0.69",
|
||||
"windivert-sys",
|
||||
"windows 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windivert-sys"
|
||||
version = "0.10.0"
|
||||
source = "git+https://github.com/EasyTier/windivert-rust.git?rev=adcc56d1550f7b5377ec2b3429f413ee24a77375#adcc56d1550f7b5377ec2b3429f413ee24a77375"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"thiserror 1.0.69",
|
||||
"windows 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f"
|
||||
dependencies = [
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.52.0"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "easytier-uptime"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
|
||||
+9
-9
@@ -9,7 +9,7 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"axios": "^1.7.9",
|
||||
"axios": "^1.13.5",
|
||||
"dayjs": "^1.11.13",
|
||||
"element-plus": "^2.8.8",
|
||||
"vue": "^3.5.18",
|
||||
@@ -1220,13 +1220,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
|
||||
"integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
|
||||
"version": "1.13.6",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
@@ -1616,9 +1616,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
||||
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"axios": "^1.7.9",
|
||||
"axios": "^1.13.5",
|
||||
"dayjs": "^1.11.13",
|
||||
"easytier-uptime-frontend": "link:",
|
||||
"element-plus": "^2.8.8",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::ops::{Div, Mul};
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
use sea_orm::{
|
||||
ColumnTrait, Condition, EntityTrait, IntoActiveModel, ModelTrait, Order, PaginatorTrait,
|
||||
QueryFilter, QueryOrder, QuerySelect, Set, TryIntoModel,
|
||||
@@ -14,7 +14,7 @@ use crate::api::{
|
||||
models::*,
|
||||
};
|
||||
use crate::db::entity::{self, health_records, shared_nodes};
|
||||
use crate::db::{operations::*, Db};
|
||||
use crate::db::{Db, operations::*};
|
||||
use crate::health_checker_manager::HealthCheckerManager;
|
||||
use axum_extra::extract::Query;
|
||||
use std::sync::Arc;
|
||||
@@ -273,7 +273,7 @@ pub struct InstanceFilterParams {
|
||||
use crate::config::AppConfig;
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use chrono::{Duration, Utc};
|
||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
@@ -370,19 +370,19 @@ pub async fn admin_get_nodes(
|
||||
let ids = NodeOperations::filter_node_ids_by_tag(&app_state.db, &tag).await?;
|
||||
filtered_ids = Some(ids);
|
||||
}
|
||||
if let Some(tags) = filters.tags {
|
||||
if !tags.is_empty() {
|
||||
let ids_any = NodeOperations::filter_node_ids_by_tags_any(&app_state.db, &tags).await?;
|
||||
filtered_ids = match filtered_ids {
|
||||
Some(mut existing) => {
|
||||
existing.extend(ids_any);
|
||||
existing.sort();
|
||||
existing.dedup();
|
||||
Some(existing)
|
||||
}
|
||||
None => Some(ids_any),
|
||||
};
|
||||
}
|
||||
if let Some(tags) = filters.tags
|
||||
&& !tags.is_empty()
|
||||
{
|
||||
let ids_any = NodeOperations::filter_node_ids_by_tags_any(&app_state.db, &tags).await?;
|
||||
filtered_ids = match filtered_ids {
|
||||
Some(mut existing) => {
|
||||
existing.extend(ids_any);
|
||||
existing.sort();
|
||||
existing.dedup();
|
||||
Some(existing)
|
||||
}
|
||||
None => Some(ids_any),
|
||||
};
|
||||
}
|
||||
if let Some(ids) = filtered_ids {
|
||||
if ids.is_empty() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use axum::routing::{delete, get, post, put};
|
||||
use axum::Router;
|
||||
use axum::routing::{delete, get, post, put};
|
||||
use tower_http::compression::CompressionLayer;
|
||||
use tower_http::cors::CorsLayer;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::db::entity::*;
|
||||
use crate::db::Db;
|
||||
use crate::db::entity::*;
|
||||
use sea_orm::*;
|
||||
use tokio::time::{sleep, Duration};
|
||||
use tokio::time::{Duration, sleep};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
/// 数据清理策略配置
|
||||
|
||||
@@ -5,12 +5,12 @@ pub mod operations;
|
||||
use std::fmt;
|
||||
|
||||
use sea_orm::{
|
||||
prelude::*, sea_query::OnConflict, ColumnTrait as _, DatabaseConnection, DbErr, EntityTrait,
|
||||
QueryFilter as _, Set, SqlxSqliteConnector, Statement, TransactionTrait as _,
|
||||
ColumnTrait as _, DatabaseConnection, DbErr, EntityTrait, QueryFilter as _, Set,
|
||||
SqlxSqliteConnector, Statement, TransactionTrait as _, prelude::*, sea_query::OnConflict,
|
||||
};
|
||||
use sea_orm_migration::MigratorTrait as _;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{migrate::MigrateDatabase as _, Sqlite, SqlitePool};
|
||||
use sqlx::{Sqlite, SqlitePool, migrate::MigrateDatabase as _};
|
||||
|
||||
use crate::migrator;
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use crate::api::CreateNodeRequest;
|
||||
use crate::db::entity::*;
|
||||
use crate::db::Db;
|
||||
use crate::db::HealthStats;
|
||||
use crate::db::HealthStatus;
|
||||
use crate::db::entity::*;
|
||||
use sea_orm::*;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
|
||||
@@ -19,9 +19,9 @@ use sqlx::any;
|
||||
use tracing::{debug, error, info, instrument, warn};
|
||||
|
||||
use crate::db::{
|
||||
Db, HealthStatus,
|
||||
entity::shared_nodes,
|
||||
operations::{HealthOperations, NodeOperations},
|
||||
Db, HealthStatus,
|
||||
};
|
||||
|
||||
pub struct HealthCheckOneNode {
|
||||
@@ -359,6 +359,7 @@ impl HealthChecker {
|
||||
)
|
||||
.parse()
|
||||
.with_context(|| "failed to parse peer uri")?,
|
||||
peer_public_key: None,
|
||||
}]);
|
||||
|
||||
let inst_id = inst_id.unwrap_or(uuid::Uuid::new_v4());
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use std::{collections::HashSet, sync::Arc, time::Duration};
|
||||
|
||||
use anyhow::Context as _;
|
||||
use tokio::time::{interval, Interval};
|
||||
use tokio::time::{Interval, interval};
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::{
|
||||
db::{entity::shared_nodes, operations::NodeOperations, Db},
|
||||
db::{Db, entity::shared_nodes, operations::NodeOperations},
|
||||
health_checker::HealthChecker,
|
||||
};
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ mod migrator;
|
||||
use api::routes::create_routes;
|
||||
use clap::Parser;
|
||||
use config::AppConfig;
|
||||
use db::{operations::NodeOperations, Db};
|
||||
use easytier::utils::init_logger;
|
||||
use db::{Db, operations::NodeOperations};
|
||||
use easytier::common::log;
|
||||
use health_checker::HealthChecker;
|
||||
use health_checker_manager::HealthCheckerManager;
|
||||
use std::env;
|
||||
@@ -42,14 +42,16 @@ async fn main() -> anyhow::Result<()> {
|
||||
let config = AppConfig::default();
|
||||
|
||||
// 初始化日志
|
||||
let _ = init_logger(&config.logging, false);
|
||||
let _ = log::init(&config.logging, false);
|
||||
|
||||
// 解析命令行参数
|
||||
let args = Args::parse();
|
||||
|
||||
// 如果提供了管理员密码,设置环境变量
|
||||
if let Some(password) = args.admin_password {
|
||||
env::set_var("ADMIN_PASSWORD", password);
|
||||
unsafe {
|
||||
env::set_var("ADMIN_PASSWORD", password);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "easytier-gui",
|
||||
"type": "module",
|
||||
"version": "2.5.0",
|
||||
"version": "2.6.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.12.1+sha512.e5a7e52a4183a02d5931057f7a0dbff9d5e9ce3161e33fa68ae392125b79282a8a8a470a51dfc8a0ed86221442eb2fb57019b0990ed24fab519bf0e1bc5ccfc4",
|
||||
"scripts": {
|
||||
@@ -53,8 +53,8 @@
|
||||
"unplugin-vue-markdown": "^0.26.2",
|
||||
"unplugin-vue-router": "^0.10.8",
|
||||
"uuid": "^10.0.0",
|
||||
"vite": "^5.4.8",
|
||||
"vite-plugin-vue-devtools": "^8.0.5",
|
||||
"vite": "^5.4.21",
|
||||
"vite-plugin-vue-devtools": "^7.4.6",
|
||||
"vite-plugin-vue-layouts": "^0.11.0",
|
||||
"vue-i18n": "^10.0.0",
|
||||
"vue-tsc": "^2.1.10"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
[package]
|
||||
name = "easytier-gui"
|
||||
version = "2.5.0"
|
||||
version = "2.6.0"
|
||||
description = "EasyTier GUI"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
edition.workspace = true
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
@@ -11,15 +11,6 @@ edition = "2021"
|
||||
name = "app_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.0.0-rc", features = [] }
|
||||
|
||||
# enable thunk-rs when compiling for x86_64 or i686 windows
|
||||
[target.x86_64-pc-windows-msvc.build-dependencies]
|
||||
thunk-rs = { git = "https://github.com/easytier/thunk.git", default-features = false, features = ["win7"] }
|
||||
|
||||
[target.i686-pc-windows-msvc.build-dependencies]
|
||||
thunk-rs = { git = "https://github.com/easytier/thunk.git", default-features = false, features = ["win7"] }
|
||||
|
||||
[dependencies]
|
||||
# wry 0.47 may crash on android, see https://github.com/EasyTier/EasyTier/issues/527
|
||||
@@ -54,6 +45,8 @@ tauri-plugin-os = "2.3.0"
|
||||
uuid = "1.17.0"
|
||||
async-trait = "0.1.89"
|
||||
|
||||
url = { version = "2.5", features = ["serde"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows = { version = "0.52", features = ["Win32_Foundation", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }
|
||||
winapi = { version = "0.3.9", features = ["securitybaseapi", "processthreadsapi"] }
|
||||
@@ -64,6 +57,14 @@ libc = "0.2"
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
security-framework-sys = "2.9.0"
|
||||
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.0.0-rc", features = [] }
|
||||
thunk-rs = { git = "https://github.com/easytier/thunk.git", default-features = false, features = [
|
||||
"win7",
|
||||
] }
|
||||
|
||||
|
||||
[features]
|
||||
# This feature is used for production builds or when a dev server is not specified, DO NOT REMOVE!!
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
fn main() {
|
||||
// enable thunk-rs when target os is windows and arch is x86_64 or i686
|
||||
#[cfg(target_os = "windows")]
|
||||
if !std::env::var("TARGET")
|
||||
.unwrap_or_default()
|
||||
.contains("aarch64")
|
||||
{
|
||||
thunk::thunk();
|
||||
}
|
||||
|
||||
tauri_build::build();
|
||||
}
|
||||
use std::env;
|
||||
|
||||
fn main() {
|
||||
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||
let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
|
||||
// enable thunk-rs when target os is windows and arch is x86_64 or i686
|
||||
if target_os == "windows" && (target_arch == "x86" || target_arch == "x86_64") {
|
||||
thunk::thunk();
|
||||
}
|
||||
|
||||
tauri_build::build();
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"core:tray:allow-set-show-menu-on-left-click",
|
||||
"core:tray:allow-set-tooltip",
|
||||
"vpnservice:allow-ping",
|
||||
"vpnservice:allow-get-vpn-status",
|
||||
"vpnservice:allow-prepare-vpn",
|
||||
"vpnservice:allow-start-vpn",
|
||||
"vpnservice:allow-stop-vpn",
|
||||
@@ -47,4 +48,4 @@
|
||||
"os:allow-platform",
|
||||
"os:allow-locale"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
use super::Command;
|
||||
use anyhow::{anyhow, Result};
|
||||
use anyhow::{Result, anyhow};
|
||||
use std::env;
|
||||
use std::ffi::OsStr;
|
||||
use std::process::{Command as StdCommand, Output};
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
use super::Command;
|
||||
use anyhow::Result;
|
||||
use std::env;
|
||||
use std::fs::File;
|
||||
use std::io::Read as _;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{ExitStatus, Output};
|
||||
|
||||
@@ -23,13 +25,15 @@ use std::ffi::{CString, OsString};
|
||||
use std::io;
|
||||
use std::mem;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
use std::os::unix::io::FromRawFd;
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
use std::path::Path;
|
||||
use std::ptr;
|
||||
|
||||
use libc::{fcntl, fileno, waitpid, EINTR, F_GETOWN};
|
||||
use libc::{EINTR, SHUT_WR, fileno, wait};
|
||||
use security_framework_sys::authorization::{
|
||||
errAuthorizationSuccess, kAuthorizationFlagDefaults, kAuthorizationFlagDestroyRights,
|
||||
AuthorizationCreate, AuthorizationExecuteWithPrivileges, AuthorizationFree, AuthorizationRef,
|
||||
errAuthorizationSuccess, kAuthorizationFlagDefaults, kAuthorizationFlagDestroyRights,
|
||||
};
|
||||
|
||||
const ENV_PATH: &str = "PATH";
|
||||
@@ -71,7 +75,7 @@ macro_rules! make_cstring {
|
||||
};
|
||||
}
|
||||
|
||||
unsafe fn gui_runas(prog: *const i8, argv: *const *const i8) -> i32 {
|
||||
unsafe fn gui_runas(prog: *const i8, argv: *const *const i8) -> io::Result<ExitStatus> {
|
||||
let mut authref: AuthorizationRef = ptr::null_mut();
|
||||
let mut pipe: *mut libc::FILE = ptr::null_mut();
|
||||
|
||||
@@ -82,7 +86,7 @@ unsafe fn gui_runas(prog: *const i8, argv: *const *const i8) -> i32 {
|
||||
&mut authref,
|
||||
) != errAuthorizationSuccess
|
||||
{
|
||||
return -1;
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
if AuthorizationExecuteWithPrivileges(
|
||||
authref,
|
||||
@@ -93,22 +97,66 @@ unsafe fn gui_runas(prog: *const i8, argv: *const *const i8) -> i32 {
|
||||
) != errAuthorizationSuccess
|
||||
{
|
||||
AuthorizationFree(authref, kAuthorizationFlagDestroyRights);
|
||||
return -1;
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
|
||||
let fd = fileno(pipe);
|
||||
if fd == -1 {
|
||||
AuthorizationFree(authref, kAuthorizationFlagDestroyRights);
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
|
||||
// We never send input to the elevated GUI. Close the parent write half so
|
||||
// the child sees EOF on stdin instead of waiting forever.
|
||||
if libc::shutdown(fd, SHUT_WR) == -1 {
|
||||
let err = io::Error::last_os_error();
|
||||
libc::fclose(pipe);
|
||||
AuthorizationFree(authref, kAuthorizationFlagDestroyRights);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
// AuthorizationExecuteWithPrivileges wires the tool's stdin/stdout to a
|
||||
// bidirectional pipe. Drain stdout so the child can't block on a full pipe.
|
||||
let read_fd = libc::dup(fd);
|
||||
if read_fd == -1 {
|
||||
let err = io::Error::last_os_error();
|
||||
libc::fclose(pipe);
|
||||
AuthorizationFree(authref, kAuthorizationFlagDestroyRights);
|
||||
return Err(err);
|
||||
}
|
||||
let mut pipe_file = unsafe { File::from_raw_fd(read_fd) };
|
||||
let mut sink = [0_u8; 8192];
|
||||
loop {
|
||||
match pipe_file.read(&mut sink) {
|
||||
Ok(0) => break,
|
||||
Ok(_) => {}
|
||||
Err(err) if err.kind() == io::ErrorKind::Interrupted => continue,
|
||||
Err(err) => {
|
||||
libc::fclose(pipe);
|
||||
AuthorizationFree(authref, kAuthorizationFlagDestroyRights);
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let pid = fcntl(fileno(pipe), F_GETOWN, 0);
|
||||
let mut status = 0;
|
||||
loop {
|
||||
let r = waitpid(pid, &mut status, 0);
|
||||
let r = wait(&mut status);
|
||||
if r == -1 && io::Error::last_os_error().raw_os_error() == Some(EINTR) {
|
||||
continue;
|
||||
} else if r == -1 {
|
||||
let err = io::Error::last_os_error();
|
||||
libc::fclose(pipe);
|
||||
AuthorizationFree(authref, kAuthorizationFlagDestroyRights);
|
||||
return Err(err);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
libc::fclose(pipe);
|
||||
AuthorizationFree(authref, kAuthorizationFlagDestroyRights);
|
||||
status
|
||||
Ok(ExitStatus::from_raw(status))
|
||||
}
|
||||
|
||||
fn runas_root_gui(cmd: &Command) -> io::Result<ExitStatus> {
|
||||
@@ -126,7 +174,7 @@ fn runas_root_gui(cmd: &Command) -> io::Result<ExitStatus> {
|
||||
let mut argv: Vec<_> = args.iter().map(|x| x.as_ptr()).collect();
|
||||
argv.push(ptr::null());
|
||||
|
||||
unsafe { Ok(mem::transmute(gui_runas(prog.as_ptr(), argv.as_ptr()))) }
|
||||
unsafe { gui_runas(prog.as_ptr(), argv.as_ptr()) }
|
||||
}
|
||||
|
||||
/// The implementation of state check and elevated executing varies on each platform
|
||||
|
||||
@@ -11,11 +11,11 @@ use std::process::{ExitStatus, Output};
|
||||
use winapi::shared::minwindef::{DWORD, LPVOID};
|
||||
use winapi::um::processthreadsapi::{GetCurrentProcess, OpenProcessToken};
|
||||
use winapi::um::securitybaseapi::GetTokenInformation;
|
||||
use winapi::um::winnt::{TokenElevation, HANDLE, TOKEN_ELEVATION, TOKEN_QUERY};
|
||||
use windows::core::{w, HSTRING, PCWSTR};
|
||||
use winapi::um::winnt::{HANDLE, TOKEN_ELEVATION, TOKEN_QUERY, TokenElevation};
|
||||
use windows::Win32::Foundation::HWND;
|
||||
use windows::Win32::UI::Shell::ShellExecuteW;
|
||||
use windows::Win32::UI::WindowsAndMessaging::SW_HIDE;
|
||||
use windows::core::{HSTRING, PCWSTR, w};
|
||||
|
||||
/// The implementation of state check and elevated executing varies on each platform
|
||||
impl Command {
|
||||
|
||||
@@ -14,16 +14,21 @@ use easytier::rpc_service::remote_client::{
|
||||
};
|
||||
use easytier::web_client::{self, WebClient};
|
||||
use easytier::{
|
||||
common::config::{ConfigLoader, FileLoggerConfig, LoggingConfigBuilder, TomlConfigLoader},
|
||||
common::{
|
||||
config::{ConfigLoader, FileLoggerConfig, LoggingConfigBuilder, TomlConfigLoader},
|
||||
log,
|
||||
},
|
||||
instance_manager::NetworkInstanceManager,
|
||||
launcher::NetworkConfig,
|
||||
rpc_service::ApiRpcServer,
|
||||
tunnel::TunnelListener,
|
||||
tunnel::ring::RingTunnelListener,
|
||||
utils::{self},
|
||||
tunnel::tcp::TcpTunnelListener,
|
||||
utils::panic::setup_panic_handler,
|
||||
};
|
||||
use std::ops::Deref;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{RwLock, RwLockReadGuard};
|
||||
use tokio::sync::{Mutex, RwLock, RwLockReadGuard};
|
||||
use uuid::Uuid;
|
||||
|
||||
use tauri::{AppHandle, Emitter, Manager as _};
|
||||
@@ -40,8 +45,21 @@ static RPC_RING_UUID: once_cell::sync::Lazy<uuid::Uuid> =
|
||||
static CLIENT_MANAGER: once_cell::sync::Lazy<RwLock<Option<manager::GUIClientManager>>> =
|
||||
once_cell::sync::Lazy::new(|| RwLock::new(None));
|
||||
|
||||
static RING_RPC_SERVER: once_cell::sync::Lazy<RwLock<Option<ApiRpcServer<RingTunnelListener>>>> =
|
||||
once_cell::sync::Lazy::new(|| RwLock::new(None));
|
||||
type BoxedTunnelListener = Box<dyn TunnelListener>;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum RpcServerKind {
|
||||
Ring,
|
||||
Tcp,
|
||||
}
|
||||
|
||||
struct RpcServer {
|
||||
kind: RpcServerKind,
|
||||
_server: ApiRpcServer<BoxedTunnelListener>,
|
||||
bind_url: Option<url::Url>,
|
||||
}
|
||||
static RPC_SERVER: once_cell::sync::Lazy<Mutex<Option<RpcServer>>> =
|
||||
once_cell::sync::Lazy::new(|| Mutex::new(None));
|
||||
|
||||
static WEB_CLIENT: once_cell::sync::Lazy<RwLock<Option<WebClient>>> =
|
||||
once_cell::sync::Lazy::new(|| RwLock::new(None));
|
||||
@@ -128,7 +146,6 @@ async fn collect_network_info(
|
||||
|
||||
#[tauri::command]
|
||||
async fn set_logging_level(level: String) -> Result<(), String> {
|
||||
println!("Setting logging level to: {}", level);
|
||||
get_client_manager!()?
|
||||
.set_logging_level(level.clone())
|
||||
.await
|
||||
@@ -173,7 +190,7 @@ async fn remove_network_instance(app: AppHandle, instance_id: String) -> Result<
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
client_manager
|
||||
.post_remove_network_instances_hook(&app, &[instance_id])
|
||||
.post_stop_network_instances_hook(&app)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
@@ -189,6 +206,16 @@ async fn update_network_config_state(
|
||||
.parse()
|
||||
.map_err(|e: uuid::Error| e.to_string())?;
|
||||
let client_manager = get_client_manager!()?;
|
||||
if !disabled {
|
||||
let cfg = client_manager
|
||||
.handle_get_network_config(app.clone(), instance_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let toml_config = cfg.gen_config().map_err(|e| e.to_string())?;
|
||||
client_manager
|
||||
.pre_run_network_instance_hook(&app, &toml_config)
|
||||
.await?;
|
||||
}
|
||||
client_manager
|
||||
.handle_update_network_state(app.clone(), instance_id, disabled)
|
||||
.await
|
||||
@@ -196,7 +223,11 @@ async fn update_network_config_state(
|
||||
|
||||
if disabled {
|
||||
client_manager
|
||||
.post_remove_network_instances_hook(&app, &[instance_id])
|
||||
.post_stop_network_instances_hook(&app)
|
||||
.await?;
|
||||
} else {
|
||||
client_manager
|
||||
.post_run_network_instance_hook(&app, &instance_id)
|
||||
.await?;
|
||||
}
|
||||
|
||||
@@ -322,8 +353,25 @@ fn get_service_status() -> Result<&'static str, String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_normal_mode_rpc_portal(portal: &str) -> Result<(url::Url, url::Url), String> {
|
||||
let portal_url: url::Url = portal
|
||||
.parse()
|
||||
.map_err(|e| format!("invalid rpc portal: {:#}", e))?;
|
||||
let bind_url = portal_url.clone();
|
||||
let mut connect_url = portal_url.clone();
|
||||
// if bind addr is 0.0.0.0, should convert to 127.0.0.1
|
||||
if connect_url.host_str() == Some("0.0.0.0") {
|
||||
connect_url.set_host(Some("127.0.0.1")).unwrap();
|
||||
}
|
||||
Ok((bind_url, connect_url))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn init_rpc_connection(_app: AppHandle, url: Option<String>) -> Result<(), String> {
|
||||
async fn init_rpc_connection(
|
||||
_app: AppHandle,
|
||||
is_normal_mode: bool,
|
||||
url: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let mut client_manager_guard =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), CLIENT_MANAGER.write())
|
||||
.await
|
||||
@@ -331,41 +379,72 @@ async fn init_rpc_connection(_app: AppHandle, url: Option<String>) -> Result<(),
|
||||
let mut instance_manager_guard = INSTANCE_MANAGER
|
||||
.try_write()
|
||||
.map_err(|_| "Failed to acquire write lock for instance manager")?;
|
||||
let mut ring_rpc_server_guard = RING_RPC_SERVER
|
||||
.try_write()
|
||||
.map_err(|_| "Failed to acquire write lock for ring rpc server")?;
|
||||
let mut rpc_server_guard = RPC_SERVER
|
||||
.try_lock()
|
||||
.map_err(|_| "Failed to acquire lock for rpc server")?;
|
||||
|
||||
let normal_mode = url.is_none();
|
||||
if normal_mode {
|
||||
let mut client_url = url.clone();
|
||||
if is_normal_mode {
|
||||
let instance_manager = if let Some(im) = instance_manager_guard.take() {
|
||||
im
|
||||
} else {
|
||||
Arc::new(NetworkInstanceManager::new())
|
||||
};
|
||||
let rpc_server = if let Some(rpc_server) = ring_rpc_server_guard.take() {
|
||||
rpc_server
|
||||
|
||||
let portal = url.and_then(|s| {
|
||||
let trimmed = s.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
}
|
||||
});
|
||||
|
||||
let (desired_kind, bind_url, connect_url) = if let Some(portal) = portal {
|
||||
let (bind_url, connect_url) = normalize_normal_mode_rpc_portal(&portal)?;
|
||||
(RpcServerKind::Tcp, Some(bind_url), Some(connect_url))
|
||||
} else {
|
||||
ApiRpcServer::from_tunnel(
|
||||
RingTunnelListener::new(
|
||||
format!("ring://{}", RPC_RING_UUID.deref()).parse().unwrap(),
|
||||
),
|
||||
instance_manager.clone(),
|
||||
)
|
||||
.with_rx_timeout(None)
|
||||
.serve()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
(RpcServerKind::Ring, None, None)
|
||||
};
|
||||
|
||||
let need_restart = rpc_server_guard
|
||||
.as_ref()
|
||||
.map(|x| x.kind != desired_kind || x.bind_url != bind_url)
|
||||
.unwrap_or(true);
|
||||
|
||||
if need_restart {
|
||||
*rpc_server_guard = None;
|
||||
|
||||
let tunnel: BoxedTunnelListener = match desired_kind {
|
||||
RpcServerKind::Ring => Box::new(RingTunnelListener::new(
|
||||
format!("ring://{}", RPC_RING_UUID.deref()).parse().unwrap(),
|
||||
)),
|
||||
RpcServerKind::Tcp => Box::new(TcpTunnelListener::new(
|
||||
bind_url.clone().expect("tcp rpc must have bind url"),
|
||||
)),
|
||||
};
|
||||
|
||||
let rpc_server = ApiRpcServer::from_tunnel(tunnel, instance_manager.clone())
|
||||
.with_rx_timeout(None)
|
||||
.serve()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
*rpc_server_guard = Some(RpcServer {
|
||||
kind: desired_kind,
|
||||
_server: rpc_server,
|
||||
bind_url,
|
||||
});
|
||||
}
|
||||
|
||||
*instance_manager_guard = Some(instance_manager);
|
||||
*ring_rpc_server_guard = Some(rpc_server);
|
||||
client_url = connect_url.map(|u| u.to_string());
|
||||
} else {
|
||||
*ring_rpc_server_guard = None;
|
||||
*rpc_server_guard = None;
|
||||
}
|
||||
|
||||
let client_manager = tokio::time::timeout(
|
||||
std::time::Duration::from_millis(1000),
|
||||
manager::GUIClientManager::new(url),
|
||||
manager::GUIClientManager::new(client_url),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "connect remote rpc timed out".to_string())?
|
||||
@@ -373,7 +452,7 @@ async fn init_rpc_connection(_app: AppHandle, url: Option<String>) -> Result<(),
|
||||
.map_err(|e| format!("{:#}", e))?;
|
||||
*client_manager_guard = Some(client_manager);
|
||||
|
||||
if !normal_mode {
|
||||
if !is_normal_mode {
|
||||
drop(WEB_CLIENT.write().await.take());
|
||||
if let Some(instance_manager) = instance_manager_guard.take() {
|
||||
instance_manager
|
||||
@@ -406,11 +485,17 @@ async fn init_web_client(app: AppHandle, url: Option<String>) -> Result<(), Stri
|
||||
|
||||
let hooks = Arc::new(manager::GuiHooks { app: app.clone() });
|
||||
|
||||
let web_client =
|
||||
web_client::run_web_client(url.as_str(), None, None, instance_manager, Some(hooks))
|
||||
.await
|
||||
.with_context(|| "Failed to initialize web client")
|
||||
.map_err(|e| format!("{:#}", e))?;
|
||||
let web_client = web_client::run_web_client(
|
||||
url.as_str(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
instance_manager,
|
||||
Some(hooks),
|
||||
)
|
||||
.await
|
||||
.with_context(|| "Failed to initialize web client")
|
||||
.map_err(|e| format!("{:#}", e))?;
|
||||
*web_client_guard = Some(web_client);
|
||||
Ok(())
|
||||
}
|
||||
@@ -450,31 +535,34 @@ async fn get_log_dir_path(app: tauri::AppHandle) -> Result<String, String> {
|
||||
#[cfg(not(target_os = "android"))]
|
||||
fn toggle_window_visibility(app: &tauri::AppHandle) {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let visible = if window.is_visible().unwrap_or_default() {
|
||||
if window.is_minimized().unwrap_or_default() {
|
||||
let _ = window.unminimize();
|
||||
false
|
||||
} else {
|
||||
true
|
||||
let visible = window.is_visible().unwrap_or_default();
|
||||
let minimized = window.is_minimized().unwrap_or_default();
|
||||
let focused = window.is_focused().unwrap_or_default();
|
||||
|
||||
let should_show = !visible || minimized || !focused;
|
||||
if should_show {
|
||||
if !visible {
|
||||
let _ = window.show();
|
||||
}
|
||||
if minimized {
|
||||
let _ = window.unminimize();
|
||||
}
|
||||
if !focused {
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
let _ = set_dock_visibility(app.clone(), true);
|
||||
} else {
|
||||
let _ = window.show();
|
||||
false
|
||||
};
|
||||
if visible {
|
||||
let _ = window.hide();
|
||||
} else {
|
||||
let _ = window.set_focus();
|
||||
let _ = set_dock_visibility(app.clone(), false);
|
||||
}
|
||||
let _ = set_dock_visibility(app.clone(), !visible);
|
||||
}
|
||||
}
|
||||
|
||||
fn get_exe_path() -> String {
|
||||
if let Ok(appimage_path) = std::env::var("APPIMAGE") {
|
||||
if !appimage_path.is_empty() {
|
||||
return appimage_path;
|
||||
}
|
||||
if let Ok(appimage_path) = std::env::var("APPIMAGE")
|
||||
&& !appimage_path.is_empty()
|
||||
{
|
||||
return appimage_path;
|
||||
}
|
||||
std::env::current_exe()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
@@ -508,8 +596,8 @@ mod manager {
|
||||
use easytier::proto::rpc_types::controller::BaseController;
|
||||
use easytier::rpc_service::logger::LoggerRpcService;
|
||||
use easytier::rpc_service::remote_client::PersistentConfig;
|
||||
use easytier::tunnel::ring::RingTunnelConnector;
|
||||
use easytier::tunnel::TunnelConnector;
|
||||
use easytier::tunnel::ring::RingTunnelConnector;
|
||||
use easytier::web_client::WebClientHooks;
|
||||
|
||||
pub(super) struct GuiHooks {
|
||||
@@ -538,7 +626,7 @@ mod manager {
|
||||
async fn post_remove_network_instances(&self, ids: &[uuid::Uuid]) -> Result<(), String> {
|
||||
let client_manager = get_client_manager!()?;
|
||||
client_manager
|
||||
.post_remove_network_instances_hook(&self.app, ids)
|
||||
.post_remote_remove_network_instances_hook(&self.app, ids)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -621,7 +709,9 @@ mod manager {
|
||||
self.network_configs.remove(network_inst_id);
|
||||
self.enabled_networks.remove(network_inst_id);
|
||||
}
|
||||
self.save_configs(&app)
|
||||
self.save_configs(&app)?;
|
||||
self.save_enabled_networks(&app)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_network_config_state(
|
||||
@@ -754,7 +844,7 @@ mod manager {
|
||||
cfg: &easytier::common::config::TomlConfigLoader,
|
||||
) -> Result<(), String> {
|
||||
let instance_id = cfg.get_id();
|
||||
app.emit("pre_run_network_instance", instance_id)
|
||||
app.emit("pre_run_network_instance", instance_id.to_string())
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
@@ -791,20 +881,21 @@ mod manager {
|
||||
let app_clone = app.clone();
|
||||
let instance_id_clone = *instance_id;
|
||||
tokio::spawn(async move {
|
||||
let instance_id_str = instance_id_clone.to_string();
|
||||
loop {
|
||||
match event_receiver.recv().await {
|
||||
Ok(easytier::common::global_ctx::GlobalCtxEvent::DhcpIpv4Changed(_, _)) => {
|
||||
let _ = app_clone.emit("dhcp_ip_changed", instance_id_clone);
|
||||
let _ = app_clone.emit("dhcp_ip_changed", &instance_id_str);
|
||||
}
|
||||
Ok(easytier::common::global_ctx::GlobalCtxEvent::ProxyCidrsUpdated(_, _)) => {
|
||||
let _ = app_clone.emit("proxy_cidrs_updated", instance_id_clone);
|
||||
let _ = app_clone.emit("proxy_cidrs_updated", &instance_id_str);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||
break;
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
|
||||
let _ = app_clone.emit("event_lagged", instance_id_clone);
|
||||
let _ = app_clone.emit("event_lagged", &instance_id_str);
|
||||
event_receiver = event_receiver.resubscribe();
|
||||
}
|
||||
}
|
||||
@@ -816,20 +907,29 @@ mod manager {
|
||||
|
||||
self.storage.enabled_networks.insert(*instance_id);
|
||||
|
||||
app.emit("post_run_network_instance", instance_id)
|
||||
app.emit("post_run_network_instance", instance_id.to_string())
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn post_remove_network_instances_hook(
|
||||
pub(super) async fn post_remote_remove_network_instances_hook(
|
||||
&self,
|
||||
app: &AppHandle,
|
||||
_ids: &[uuid::Uuid],
|
||||
ids: &[uuid::Uuid],
|
||||
) -> Result<(), String> {
|
||||
self.storage
|
||||
.enabled_networks
|
||||
.retain(|id| !_ids.contains(id));
|
||||
.delete_network_configs(app.clone(), ids)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
self.notify_vpn_stop_if_no_tun(app)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn post_stop_network_instances_hook(
|
||||
&self,
|
||||
app: &AppHandle,
|
||||
) -> Result<(), String> {
|
||||
self.notify_vpn_stop_if_no_tun(app)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -879,28 +979,34 @@ mod manager {
|
||||
.get_rpc_client(app.clone())
|
||||
.ok_or_else(|| anyhow::anyhow!("RPC client not found"))?;
|
||||
for id in enabled_networks {
|
||||
if let Ok(uuid) = id.parse() {
|
||||
if !self.storage.enabled_networks.contains(&uuid) {
|
||||
let config = self
|
||||
.storage
|
||||
.network_configs
|
||||
.get(&uuid)
|
||||
.map(|i| i.value().1.clone());
|
||||
if config.is_none() {
|
||||
continue;
|
||||
}
|
||||
client
|
||||
.run_network_instance(
|
||||
BaseController::default(),
|
||||
RunNetworkInstanceRequest {
|
||||
inst_id: None,
|
||||
config,
|
||||
overwrite: false,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
self.storage.enabled_networks.insert(uuid);
|
||||
}
|
||||
if let Ok(uuid) = id.parse()
|
||||
&& !self.storage.enabled_networks.contains(&uuid)
|
||||
{
|
||||
let config = self
|
||||
.storage
|
||||
.network_configs
|
||||
.get(&uuid)
|
||||
.map(|i| i.value().1.clone());
|
||||
let Some(config) = config else {
|
||||
continue;
|
||||
};
|
||||
let toml_config = config.gen_config()?;
|
||||
self.pre_run_network_instance_hook(&app, &toml_config)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
client
|
||||
.run_network_instance(
|
||||
BaseController::default(),
|
||||
RunNetworkInstanceRequest {
|
||||
inst_id: None,
|
||||
config: Some(config),
|
||||
overwrite: false,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
self.post_run_network_instance_hook(&app, &uuid)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -1014,7 +1120,7 @@ pub fn run_gui() -> std::process::ExitCode {
|
||||
process::exit(0);
|
||||
}
|
||||
|
||||
utils::setup_panic_handler();
|
||||
setup_panic_handler();
|
||||
|
||||
let mut builder = tauri::Builder::default();
|
||||
|
||||
@@ -1053,7 +1159,7 @@ pub fn run_gui() -> std::process::ExitCode {
|
||||
})
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let Ok(_) = utils::init_logger(&config, true) else {
|
||||
let Ok(_) = log::init(&config, true) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"createUpdaterArtifacts": false
|
||||
},
|
||||
"productName": "easytier-gui",
|
||||
"version": "2.5.0",
|
||||
"version": "2.6.0",
|
||||
"identifier": "com.kkrainbow.easytier",
|
||||
"plugins": {
|
||||
"shell": {
|
||||
|
||||
Vendored
+6
@@ -43,6 +43,7 @@ declare global {
|
||||
const isWebClientConnected: typeof import('./composables/backend')['isWebClientConnected']
|
||||
const listNetworkInstanceIds: typeof import('./composables/backend')['listNetworkInstanceIds']
|
||||
const listenGlobalEvents: typeof import('./composables/event')['listenGlobalEvents']
|
||||
const loadLastNetworkInstanceId: typeof import('./composables/config')['loadLastNetworkInstanceId']
|
||||
const loadMode: typeof import('./composables/mode')['loadMode']
|
||||
const mapActions: typeof import('pinia')['mapActions']
|
||||
const mapGetters: typeof import('pinia')['mapGetters']
|
||||
@@ -76,6 +77,7 @@ declare global {
|
||||
const ref: typeof import('vue')['ref']
|
||||
const resolveComponent: typeof import('vue')['resolveComponent']
|
||||
const runNetworkInstance: typeof import('./composables/backend')['runNetworkInstance']
|
||||
const saveLastNetworkInstanceId: typeof import('./composables/config')['saveLastNetworkInstanceId']
|
||||
const saveMode: typeof import('./composables/mode')['saveMode']
|
||||
const saveNetworkConfig: typeof import('./composables/backend')['saveNetworkConfig']
|
||||
const sendConfigs: typeof import('./composables/backend')['sendConfigs']
|
||||
@@ -91,6 +93,7 @@ declare global {
|
||||
const shallowReadonly: typeof import('vue')['shallowReadonly']
|
||||
const shallowRef: typeof import('vue')['shallowRef']
|
||||
const storeToRefs: typeof import('pinia')['storeToRefs']
|
||||
const syncMobileVpnService: typeof import('./composables/mobile_vpn')['syncMobileVpnService']
|
||||
const toRaw: typeof import('vue')['toRaw']
|
||||
const toRef: typeof import('vue')['toRef']
|
||||
const toRefs: typeof import('vue')['toRefs']
|
||||
@@ -165,6 +168,7 @@ declare module 'vue' {
|
||||
readonly isWebClientConnected: UnwrapRef<typeof import('./composables/backend')['isWebClientConnected']>
|
||||
readonly listNetworkInstanceIds: UnwrapRef<typeof import('./composables/backend')['listNetworkInstanceIds']>
|
||||
readonly listenGlobalEvents: UnwrapRef<typeof import('./composables/event')['listenGlobalEvents']>
|
||||
readonly loadLastNetworkInstanceId: UnwrapRef<typeof import('./composables/config')['loadLastNetworkInstanceId']>
|
||||
readonly loadMode: UnwrapRef<typeof import('./composables/mode')['loadMode']>
|
||||
readonly mapActions: UnwrapRef<typeof import('pinia')['mapActions']>
|
||||
readonly mapGetters: UnwrapRef<typeof import('pinia')['mapGetters']>
|
||||
@@ -198,6 +202,7 @@ declare module 'vue' {
|
||||
readonly ref: UnwrapRef<typeof import('vue')['ref']>
|
||||
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
|
||||
readonly runNetworkInstance: UnwrapRef<typeof import('./composables/backend')['runNetworkInstance']>
|
||||
readonly saveLastNetworkInstanceId: UnwrapRef<typeof import('./composables/config')['saveLastNetworkInstanceId']>
|
||||
readonly saveMode: UnwrapRef<typeof import('./composables/mode')['saveMode']>
|
||||
readonly saveNetworkConfig: UnwrapRef<typeof import('./composables/backend')['saveNetworkConfig']>
|
||||
readonly sendConfigs: UnwrapRef<typeof import('./composables/backend')['sendConfigs']>
|
||||
@@ -213,6 +218,7 @@ declare module 'vue' {
|
||||
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
|
||||
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
|
||||
readonly storeToRefs: UnwrapRef<typeof import('pinia')['storeToRefs']>
|
||||
readonly syncMobileVpnService: UnwrapRef<typeof import('./composables/mobile_vpn')['syncMobileVpnService']>
|
||||
readonly toRaw: UnwrapRef<typeof import('vue')['toRaw']>
|
||||
readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
|
||||
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watch, onMounted, ref } from 'vue';
|
||||
import type { Mode, ServiceMode, RemoteMode } from '~/composables/mode';
|
||||
import type { Mode, ServiceMode, RemoteMode, NormalMode } from '~/composables/mode';
|
||||
import { appConfigDir, appLogDir } from '@tauri-apps/api/path';
|
||||
import { join } from '@tauri-apps/api/path';
|
||||
import { getServiceStatus, type ServiceStatus } from '~/composables/backend';
|
||||
@@ -15,6 +15,14 @@ const defaultLogDir = ref('')
|
||||
const serviceStatus = ref<ServiceStatus>('NotInstalled')
|
||||
const isServiceStatusLoaded = ref(false)
|
||||
|
||||
function normalizeRpcListenPort(port: unknown): number {
|
||||
const defaultPort = 15999
|
||||
const numericPort = typeof port === 'number' ? port : Number.parseInt(String(port ?? ''), 10)
|
||||
if (Number.isNaN(numericPort))
|
||||
return defaultPort
|
||||
return Math.min(65535, Math.max(1, Math.floor(numericPort)))
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
defaultConfigDir.value = await join(await appConfigDir(), 'config.d')
|
||||
defaultLogDir.value = await appLogDir()
|
||||
@@ -26,6 +34,43 @@ const modeOptions = computed(() => [
|
||||
{ label: t('mode.remote'), value: 'remote' },
|
||||
]);
|
||||
|
||||
const normalMode = computed({
|
||||
get: () => model.value.mode === 'normal' ? model.value as NormalMode : undefined,
|
||||
set: (value) => {
|
||||
if (value) {
|
||||
model.value = value
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const rpcListenOptions = computed(() => [
|
||||
{ label: t('web.common.disable'), value: false },
|
||||
{ label: t('web.common.enable'), value: true },
|
||||
])
|
||||
|
||||
const rpcListenEnabled = computed<boolean>({
|
||||
get: () => !!normalMode.value?.enable_rpc_port_listen,
|
||||
set: (value) => {
|
||||
if (!normalMode.value)
|
||||
return
|
||||
normalMode.value.enable_rpc_port_listen = value
|
||||
},
|
||||
})
|
||||
|
||||
const rpcListenPort = computed<string>({
|
||||
get: () => String(normalMode.value?.rpc_listen_port ?? 15999),
|
||||
set: (value) => {
|
||||
if (!normalMode.value)
|
||||
return
|
||||
const trimmed = value.trim()
|
||||
if (trimmed === '')
|
||||
return
|
||||
if (!/^\d+$/.test(trimmed))
|
||||
return
|
||||
normalMode.value.rpc_listen_port = Number.parseInt(trimmed, 10)
|
||||
},
|
||||
})
|
||||
|
||||
const serviceMode = computed({
|
||||
get: () => model.value.mode === 'service' ? model.value as ServiceMode : undefined,
|
||||
set: (value) => {
|
||||
@@ -57,6 +102,24 @@ const statusColorClass = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => [normalMode.value?.enable_rpc_port_listen, normalMode.value?.rpc_listen_port], ([enabled, port]) => {
|
||||
if (!normalMode.value)
|
||||
return
|
||||
|
||||
if (!enabled) {
|
||||
normalMode.value.rpc_portal = undefined
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedPort = normalizeRpcListenPort(port)
|
||||
if (normalMode.value.rpc_listen_port !== normalizedPort)
|
||||
normalMode.value.rpc_listen_port = normalizedPort
|
||||
|
||||
const desiredPortal = `tcp://0.0.0.0:${normalizedPort}`
|
||||
if (normalMode.value.rpc_portal !== desiredPortal)
|
||||
normalMode.value.rpc_portal = desiredPortal
|
||||
}, { immediate: true })
|
||||
|
||||
watch(() => model.value.mode, async (newMode, oldMode) => {
|
||||
if (newMode === oldMode)
|
||||
return
|
||||
@@ -69,8 +132,12 @@ watch(() => model.value.mode, async (newMode, oldMode) => {
|
||||
const oldModelValue = { ...model.value }
|
||||
|
||||
if (newMode === 'normal') {
|
||||
const portal = normalMode.value?.rpc_portal?.trim()
|
||||
model.value = {
|
||||
...oldModelValue,
|
||||
rpc_portal: portal || undefined,
|
||||
enable_rpc_port_listen: normalMode.value?.enable_rpc_port_listen,
|
||||
rpc_listen_port: normalMode.value?.rpc_listen_port,
|
||||
mode: 'normal',
|
||||
}
|
||||
}
|
||||
@@ -113,6 +180,20 @@ watch(() => model.value.mode, async (newMode, oldMode) => {
|
||||
{{ t('mode.remote_description') }}
|
||||
</div>
|
||||
|
||||
<div v-if="normalMode" class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="rpc-listen-toggle">{{ t('mode.enable_rpc_tcp_listen') }}</label>
|
||||
<SelectButton id="rpc-listen-toggle" v-model="rpcListenEnabled" :options="rpcListenOptions" option-label="label"
|
||||
option-value="value" />
|
||||
</div>
|
||||
<div v-if="rpcListenEnabled" class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="rpc-listen-port">{{ t('mode.rpc_listen_port') }}</label>
|
||||
<InputText id="rpc-listen-port" v-model="rpcListenPort" class="flex-1" inputmode="numeric" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="serviceMode" class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="config-dir">{{ t('mode.config_dir') }}</label>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { Api, type NetworkTypes } from 'easytier-frontend-lib'
|
||||
import { Api, NetworkTypes } from 'easytier-frontend-lib'
|
||||
import { GetNetworkMetasResponse } from 'node_modules/easytier-frontend-lib/dist/modules/api'
|
||||
|
||||
|
||||
@@ -17,15 +17,16 @@ interface ServiceOptions {
|
||||
export type ServiceStatus = "Running" | "Stopped" | "NotInstalled"
|
||||
|
||||
export async function parseNetworkConfig(cfg: NetworkConfig) {
|
||||
return invoke<string>('parse_network_config', { cfg })
|
||||
return invoke<string>('parse_network_config', { cfg: NetworkTypes.toBackendNetworkConfig(cfg) })
|
||||
}
|
||||
|
||||
export async function generateNetworkConfig(tomlConfig: string) {
|
||||
return invoke<NetworkConfig>('generate_network_config', { tomlConfig })
|
||||
const config = await invoke<NetworkConfig>('generate_network_config', { tomlConfig })
|
||||
return NetworkTypes.normalizeNetworkConfig(config)
|
||||
}
|
||||
|
||||
export async function runNetworkInstance(cfg: NetworkConfig, save: boolean) {
|
||||
return invoke('run_network_instance', { cfg, save })
|
||||
return invoke('run_network_instance', { cfg: NetworkTypes.toBackendNetworkConfig(cfg), save })
|
||||
}
|
||||
|
||||
export async function collectNetworkInfo(instanceId: string) {
|
||||
@@ -57,20 +58,24 @@ export async function updateNetworkConfigState(instanceId: string, disabled: boo
|
||||
}
|
||||
|
||||
export async function saveNetworkConfig(cfg: NetworkConfig) {
|
||||
return await invoke('save_network_config', { cfg })
|
||||
return await invoke('save_network_config', { cfg: NetworkTypes.toBackendNetworkConfig(cfg) })
|
||||
}
|
||||
|
||||
export async function validateConfig(cfg: NetworkConfig) {
|
||||
return await invoke<ValidateConfigResponse>('validate_config', { cfg })
|
||||
return await invoke<ValidateConfigResponse>('validate_config', { cfg: NetworkTypes.toBackendNetworkConfig(cfg) })
|
||||
}
|
||||
|
||||
export async function getConfig(instanceId: string) {
|
||||
return await invoke<NetworkConfig>('get_config', { instanceId })
|
||||
const config = await invoke<NetworkConfig>('get_config', { instanceId })
|
||||
return NetworkTypes.normalizeNetworkConfig(config)
|
||||
}
|
||||
|
||||
export async function sendConfigs(enabledNetworks: string[]) {
|
||||
let networkList: NetworkConfig[] = JSON.parse(localStorage.getItem('networkList') || '[]');
|
||||
return await invoke('load_configs', { configs: networkList, enabledNetworks })
|
||||
const networkList: NetworkConfig[] = JSON.parse(localStorage.getItem('networkList') || '[]');
|
||||
return await invoke('load_configs', {
|
||||
configs: networkList.map((config) => NetworkTypes.toBackendNetworkConfig(NetworkTypes.normalizeNetworkConfig(config))),
|
||||
enabledNetworks
|
||||
})
|
||||
}
|
||||
|
||||
export async function getNetworkMetas(instanceIds: string[]) {
|
||||
@@ -89,8 +94,8 @@ export async function getServiceStatus() {
|
||||
return await invoke<ServiceStatus>('get_service_status')
|
||||
}
|
||||
|
||||
export async function initRpcConnection(url?: string) {
|
||||
return await invoke('init_rpc_connection', { url })
|
||||
export async function initRpcConnection(isNormalMode: boolean, url?: string) {
|
||||
return await invoke('init_rpc_connection', { isNormalMode, url })
|
||||
}
|
||||
|
||||
export async function isClientRunning() {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 配置持久化相关的函数
|
||||
* 用于保存和加载应用程序的各种配置状态
|
||||
*/
|
||||
|
||||
/**
|
||||
* 保存上次使用的网络实例 ID
|
||||
* @param instanceId 网络实例 ID
|
||||
*/
|
||||
export function saveLastNetworkInstanceId(instanceId: string) {
|
||||
localStorage.setItem('last_network_instance_id', instanceId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载上次使用的网络实例 ID
|
||||
* @returns 上次使用的网络实例 ID,如果没有则返回 null
|
||||
*/
|
||||
export function loadLastNetworkInstanceId(): string | null {
|
||||
return localStorage.getItem('last_network_instance_id')
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Event, listen } from "@tauri-apps/api/event";
|
||||
import { type } from "@tauri-apps/plugin-os";
|
||||
import { NetworkTypes } from "easytier-frontend-lib"
|
||||
import { Utils } from "easytier-frontend-lib";
|
||||
|
||||
const EVENTS = Object.freeze({
|
||||
SAVE_CONFIGS: 'save_configs',
|
||||
@@ -14,42 +15,74 @@ const EVENTS = Object.freeze({
|
||||
|
||||
function onSaveConfigs(event: Event<NetworkTypes.NetworkConfig[]>) {
|
||||
console.log(`Received event '${EVENTS.SAVE_CONFIGS}': ${event.payload}`);
|
||||
localStorage.setItem('networkList', JSON.stringify(event.payload));
|
||||
localStorage.setItem('networkList', JSON.stringify(event.payload.map((config) => NetworkTypes.normalizeNetworkConfig(config))));
|
||||
}
|
||||
|
||||
async function onPreRunNetworkInstance(event: Event<string>) {
|
||||
function normalizeInstanceIdPayload(payload: unknown): string {
|
||||
if (typeof payload === 'string') {
|
||||
return payload
|
||||
}
|
||||
|
||||
if (payload && typeof payload === 'object') {
|
||||
const uuid = payload as Partial<Utils.UUID>
|
||||
if (
|
||||
typeof uuid.part1 === 'number'
|
||||
&& typeof uuid.part2 === 'number'
|
||||
&& typeof uuid.part3 === 'number'
|
||||
&& typeof uuid.part4 === 'number'
|
||||
) {
|
||||
return Utils.UuidToStr(uuid as Utils.UUID)
|
||||
}
|
||||
}
|
||||
|
||||
if (payload == null) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const fallback = String(payload)
|
||||
return fallback === '[object Object]' ? '' : fallback
|
||||
}
|
||||
|
||||
async function onPreRunNetworkInstance(event: Event<unknown>) {
|
||||
const instanceId = normalizeInstanceIdPayload(event.payload)
|
||||
console.log(`Received event '${EVENTS.PRE_RUN_NETWORK_INSTANCE}', raw payload:`, event.payload, 'normalized:', instanceId)
|
||||
if (type() === 'android') {
|
||||
await prepareVpnService(event.payload);
|
||||
await prepareVpnService(instanceId);
|
||||
}
|
||||
}
|
||||
|
||||
async function onPostRunNetworkInstance(event: Event<string>) {
|
||||
async function onPostRunNetworkInstance(event: Event<unknown>) {
|
||||
const instanceId = normalizeInstanceIdPayload(event.payload)
|
||||
console.log(`Received event '${EVENTS.POST_RUN_NETWORK_INSTANCE}', raw payload:`, event.payload, 'normalized:', instanceId)
|
||||
if (type() === 'android') {
|
||||
await onNetworkInstanceChange(event.payload);
|
||||
await onNetworkInstanceChange(instanceId);
|
||||
}
|
||||
}
|
||||
|
||||
async function onVpnServiceStop(event: Event<string>) {
|
||||
await onNetworkInstanceChange(event.payload);
|
||||
async function onVpnServiceStop(event: Event<unknown>) {
|
||||
console.log(`Received event '${EVENTS.VPN_SERVICE_STOP}', raw payload:`, event.payload)
|
||||
await syncMobileVpnService();
|
||||
}
|
||||
|
||||
async function onDhcpIpChanged(event: Event<string>) {
|
||||
console.log(`Received event '${EVENTS.DHCP_IP_CHANGED}' for instance: ${event.payload}`);
|
||||
async function onDhcpIpChanged(event: Event<unknown>) {
|
||||
const instanceId = normalizeInstanceIdPayload(event.payload)
|
||||
console.log(`Received event '${EVENTS.DHCP_IP_CHANGED}' for instance: ${instanceId}`);
|
||||
if (type() === 'android') {
|
||||
await onNetworkInstanceChange(event.payload);
|
||||
await onNetworkInstanceChange(instanceId);
|
||||
}
|
||||
}
|
||||
|
||||
async function onProxyCidrsUpdated(event: Event<string>) {
|
||||
console.log(`Received event '${EVENTS.PROXY_CIDRS_UPDATED}' for instance: ${event.payload}`);
|
||||
async function onProxyCidrsUpdated(event: Event<unknown>) {
|
||||
const instanceId = normalizeInstanceIdPayload(event.payload)
|
||||
console.log(`Received event '${EVENTS.PROXY_CIDRS_UPDATED}' for instance: ${instanceId}`);
|
||||
if (type() === 'android') {
|
||||
await onNetworkInstanceChange(event.payload);
|
||||
await onNetworkInstanceChange(instanceId);
|
||||
}
|
||||
}
|
||||
|
||||
async function onEventLagged(event: Event<string>) {
|
||||
async function onEventLagged(event: Event<unknown>) {
|
||||
if (type() === 'android') {
|
||||
await onNetworkInstanceChange(event.payload);
|
||||
await onNetworkInstanceChange(normalizeInstanceIdPayload(event.payload));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NetworkTypes } from 'easytier-frontend-lib'
|
||||
import { addPluginListener } from '@tauri-apps/api/core'
|
||||
import { Utils } from 'easytier-frontend-lib'
|
||||
import { prepare_vpn, start_vpn, stop_vpn } from 'tauri-plugin-vpnservice-api'
|
||||
import { get_vpn_status, prepare_vpn, start_vpn, stop_vpn } from 'tauri-plugin-vpnservice-api'
|
||||
|
||||
type Route = NetworkTypes.Route
|
||||
|
||||
@@ -24,6 +24,53 @@ const curVpnStatus: vpnStatus = {
|
||||
dns: undefined,
|
||||
}
|
||||
|
||||
async function requestVpnPermission() {
|
||||
console.log('prepare vpn')
|
||||
const prepare_ret = await prepare_vpn()
|
||||
console.log('prepare vpn', JSON.stringify((prepare_ret)))
|
||||
if (prepare_ret?.errorMsg?.length) {
|
||||
throw new Error(prepare_ret.errorMsg)
|
||||
}
|
||||
|
||||
const granted = prepare_ret?.granted ?? true
|
||||
if (!granted) {
|
||||
console.info('vpn permission request was denied or dismissed')
|
||||
}
|
||||
|
||||
return granted
|
||||
}
|
||||
|
||||
function resetVpnConfigStatus() {
|
||||
curVpnStatus.ipv4Addr = undefined
|
||||
curVpnStatus.ipv4Cidr = undefined
|
||||
curVpnStatus.routes = []
|
||||
curVpnStatus.dns = undefined
|
||||
}
|
||||
|
||||
function syncVpnStatusFromNative(status: Awaited<ReturnType<typeof get_vpn_status>>) {
|
||||
curVpnStatus.running = status?.running ?? false
|
||||
if (!curVpnStatus.running) {
|
||||
resetVpnConfigStatus()
|
||||
return
|
||||
}
|
||||
|
||||
const ipv4WithCidr = status?.ipv4Addr
|
||||
if (ipv4WithCidr?.length) {
|
||||
const [ipv4Addr, cidr] = ipv4WithCidr.split('/')
|
||||
curVpnStatus.ipv4Addr = ipv4Addr
|
||||
|
||||
const parsedCidr = Number(cidr)
|
||||
curVpnStatus.ipv4Cidr = Number.isInteger(parsedCidr) ? parsedCidr : undefined
|
||||
}
|
||||
else {
|
||||
curVpnStatus.ipv4Addr = undefined
|
||||
curVpnStatus.ipv4Cidr = undefined
|
||||
}
|
||||
|
||||
curVpnStatus.routes = [...(status?.routes ?? [])]
|
||||
curVpnStatus.dns = status?.dns ?? undefined
|
||||
}
|
||||
|
||||
async function waitVpnStatus(target_status: boolean, timeout_sec: number) {
|
||||
const start_time = Date.now()
|
||||
while (curVpnStatus.running !== target_status) {
|
||||
@@ -34,18 +81,19 @@ async function waitVpnStatus(target_status: boolean, timeout_sec: number) {
|
||||
}
|
||||
}
|
||||
|
||||
async function doStopVpn() {
|
||||
if (!curVpnStatus.running) {
|
||||
async function doStopVpn(force = false) {
|
||||
const wasRunning = curVpnStatus.running
|
||||
if (!force && !wasRunning) {
|
||||
return
|
||||
}
|
||||
console.log('stop vpn')
|
||||
const stop_ret = await stop_vpn()
|
||||
console.log('stop vpn', JSON.stringify((stop_ret)))
|
||||
await waitVpnStatus(false, 3)
|
||||
if (wasRunning) {
|
||||
await waitVpnStatus(false, 3)
|
||||
}
|
||||
|
||||
curVpnStatus.ipv4Addr = undefined
|
||||
curVpnStatus.routes = []
|
||||
curVpnStatus.dns = undefined
|
||||
resetVpnConfigStatus()
|
||||
}
|
||||
|
||||
async function doStartVpn(ipv4Addr: string, cidr: number, routes: string[], dns?: string) {
|
||||
@@ -54,19 +102,32 @@ async function doStartVpn(ipv4Addr: string, cidr: number, routes: string[], dns?
|
||||
}
|
||||
|
||||
console.log('start vpn service', ipv4Addr, cidr, routes, dns)
|
||||
const start_ret = await start_vpn({
|
||||
const request = {
|
||||
ipv4Addr: `${ipv4Addr}/${cidr}`,
|
||||
routes,
|
||||
dns,
|
||||
disallowedApplications: ['com.kkrainbow.easytier'],
|
||||
mtu: 1300,
|
||||
})
|
||||
}
|
||||
|
||||
let start_ret = await start_vpn(request)
|
||||
console.log('start vpn response', JSON.stringify(start_ret))
|
||||
if (start_ret?.errorMsg === 'need_prepare') {
|
||||
const granted = await requestVpnPermission()
|
||||
if (!granted) {
|
||||
throw new Error('vpn_permission_denied')
|
||||
}
|
||||
start_ret = await start_vpn(request)
|
||||
console.log('start vpn retry response', JSON.stringify(start_ret))
|
||||
}
|
||||
|
||||
if (start_ret?.errorMsg?.length) {
|
||||
throw new Error(start_ret.errorMsg)
|
||||
}
|
||||
await waitVpnStatus(true, 3)
|
||||
|
||||
curVpnStatus.ipv4Addr = ipv4Addr
|
||||
curVpnStatus.ipv4Cidr = cidr
|
||||
curVpnStatus.routes = routes
|
||||
curVpnStatus.dns = dns
|
||||
}
|
||||
@@ -75,13 +136,16 @@ async function onVpnServiceStart(payload: any) {
|
||||
console.log('vpn service start', JSON.stringify(payload))
|
||||
curVpnStatus.running = true
|
||||
if (payload.fd) {
|
||||
setTunFd(payload.fd)
|
||||
await setTunFd(payload.fd).catch((e) => {
|
||||
console.error('set tun fd failed', e)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function onVpnServiceStop(payload: any) {
|
||||
console.log('vpn service stop', JSON.stringify(payload))
|
||||
curVpnStatus.running = false
|
||||
resetVpnConfigStatus()
|
||||
}
|
||||
|
||||
async function registerVpnServiceListener() {
|
||||
@@ -135,15 +199,25 @@ export async function onNetworkInstanceChange(instanceId: string) {
|
||||
}
|
||||
|
||||
if (!instanceId) {
|
||||
await doStopVpn()
|
||||
console.warn('vpn service skipped because instance id is empty')
|
||||
if (curVpnStatus.running) {
|
||||
await doStopVpn()
|
||||
}
|
||||
return
|
||||
}
|
||||
const config = await getConfig(instanceId)
|
||||
console.log('vpn service loaded config', instanceId, JSON.stringify({
|
||||
no_tun: config.no_tun,
|
||||
dhcp: config.dhcp,
|
||||
enable_magic_dns: config.enable_magic_dns,
|
||||
}))
|
||||
if (config.no_tun) {
|
||||
console.log('vpn service skipped because no_tun is enabled', instanceId)
|
||||
return
|
||||
}
|
||||
const curNetworkInfo = (await collectNetworkInfo(instanceId)).info.map[instanceId]
|
||||
if (!curNetworkInfo || curNetworkInfo?.error_msg?.length) {
|
||||
console.warn('vpn service skipped because network info is unavailable', instanceId, curNetworkInfo?.error_msg)
|
||||
await doStopVpn()
|
||||
return
|
||||
}
|
||||
@@ -170,27 +244,39 @@ export async function onNetworkInstanceChange(instanceId: string) {
|
||||
|
||||
const routes = getRoutesForVpn(curNetworkInfo?.routes, config)
|
||||
|
||||
const dns = config.enable_magic_dns ? '100.100.100.101' : undefined;
|
||||
const dns = config.enable_magic_dns ? '100.100.100.101' : undefined
|
||||
|
||||
const ipChanged = virtual_ip !== curVpnStatus.ipv4Addr
|
||||
const cidrChanged = network_length !== curVpnStatus.ipv4Cidr
|
||||
const routesChanged = JSON.stringify(routes) !== JSON.stringify(curVpnStatus.routes)
|
||||
const dnsChanged = dns != curVpnStatus.dns
|
||||
const configChanged = ipChanged || cidrChanged || routesChanged || dnsChanged
|
||||
const shouldStartVpn = !curVpnStatus.running
|
||||
|
||||
if (ipChanged || routesChanged || dnsChanged) {
|
||||
if (shouldStartVpn || configChanged) {
|
||||
console.info('vpn service virtual ip changed', JSON.stringify(curVpnStatus), virtual_ip)
|
||||
try {
|
||||
await doStopVpn()
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e)
|
||||
if (curVpnStatus.running) {
|
||||
try {
|
||||
await doStopVpn()
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await doStartVpn(virtual_ip, network_length, routes, dns)
|
||||
}
|
||||
catch (e) {
|
||||
console.error('start vpn service failed, stop all other network insts.', e)
|
||||
await runNetworkInstance(config, true); //on android config should always be saved
|
||||
if (e instanceof Error && e.message === 'need_prepare') {
|
||||
console.info('vpn permission is required before starting the Android VPN service')
|
||||
return
|
||||
}
|
||||
if (e instanceof Error && e.message === 'vpn_permission_denied') {
|
||||
console.info('vpn permission request was denied or dismissed')
|
||||
return
|
||||
}
|
||||
console.error('start vpn service failed', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,6 +288,22 @@ async function isNoTunEnabled(instanceId: string | undefined) {
|
||||
return (await getConfig(instanceId)).no_tun ?? false
|
||||
}
|
||||
|
||||
async function findRunningTunInstanceId() {
|
||||
const instanceIds = await listNetworkInstanceIds()
|
||||
const runningIds = instanceIds.running_inst_ids.map(Utils.UuidToStr)
|
||||
console.log('vpn service sync running instances', JSON.stringify(runningIds))
|
||||
|
||||
for (const instanceId of runningIds) {
|
||||
if (await isNoTunEnabled(instanceId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
return instanceId
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export async function initMobileVpnService() {
|
||||
await registerVpnServiceListener()
|
||||
}
|
||||
@@ -210,10 +312,22 @@ export async function prepareVpnService(instanceId: string) {
|
||||
if (await isNoTunEnabled(instanceId)) {
|
||||
return
|
||||
}
|
||||
console.log('prepare vpn')
|
||||
const prepare_ret = await prepare_vpn()
|
||||
console.log('prepare vpn', JSON.stringify((prepare_ret)))
|
||||
if (prepare_ret?.errorMsg?.length) {
|
||||
throw new Error(prepare_ret.errorMsg)
|
||||
await requestVpnPermission()
|
||||
}
|
||||
|
||||
export async function syncMobileVpnService() {
|
||||
syncVpnStatusFromNative(await get_vpn_status())
|
||||
const instanceId = await findRunningTunInstanceId()
|
||||
if (instanceId) {
|
||||
console.log('vpn service sync selected instance', instanceId)
|
||||
await onNetworkInstanceChange(instanceId)
|
||||
return
|
||||
}
|
||||
|
||||
if (dhcpPollingTimer) {
|
||||
clearTimeout(dhcpPollingTimer)
|
||||
dhcpPollingTimer = null
|
||||
}
|
||||
|
||||
await doStopVpn(true)
|
||||
}
|
||||
|
||||
@@ -4,8 +4,12 @@ export interface WebClientConfig {
|
||||
config_server_url?: string
|
||||
}
|
||||
|
||||
interface NormalMode extends WebClientConfig {
|
||||
export interface NormalMode extends WebClientConfig {
|
||||
mode: 'normal'
|
||||
// if not provided will use ring tunnel rpc server
|
||||
rpc_portal?: string
|
||||
enable_rpc_port_listen?: boolean
|
||||
rpc_listen_port?: number
|
||||
}
|
||||
|
||||
export interface ServiceMode extends WebClientConfig {
|
||||
|
||||
@@ -9,10 +9,12 @@ import { exit } from '@tauri-apps/plugin-process'
|
||||
import { I18nUtils, RemoteManagement, Utils } from "easytier-frontend-lib"
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { useTray } from '~/composables/tray'
|
||||
import { initMobileVpnService } from '~/composables/mobile_vpn'
|
||||
import { GUIRemoteClient } from '~/modules/api'
|
||||
|
||||
import { useToast, useConfirm } from 'primevue'
|
||||
import { loadMode, saveMode, WebClientConfig, type Mode } from '~/composables/mode'
|
||||
import { saveLastNetworkInstanceId, loadLastNetworkInstanceId } from '~/composables/config'
|
||||
import ModeSwitcher from '~/components/ModeSwitcher.vue'
|
||||
import { getServiceStatus } from '~/composables/backend'
|
||||
|
||||
@@ -155,13 +157,23 @@ async function initWithMode(mode: Mode) {
|
||||
url = "tcp://" + mode.rpc_portal.replace("0.0.0.0", "127.0.0.1")
|
||||
retrys = 5
|
||||
break;
|
||||
case 'normal':
|
||||
url = mode.rpc_portal;
|
||||
break;
|
||||
}
|
||||
for (let i = 0; i < retrys; i++) {
|
||||
try {
|
||||
await connectRpcClient(url)
|
||||
await connectRpcClient(mode.mode === 'normal', url)
|
||||
break;
|
||||
} catch (e) {
|
||||
if (i === retrys - 1) {
|
||||
const errMsg = e instanceof Error ? e.message : String(e)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('error'),
|
||||
detail: t('mode.rpc_connection_failed', { error: errMsg }),
|
||||
life: 1000,
|
||||
})
|
||||
throw e;
|
||||
}
|
||||
console.error("Error connecting rpc client, retrying...", e)
|
||||
@@ -178,9 +190,25 @@ async function initWithMode(mode: Mode) {
|
||||
clientRunning.value = await isClientRunning()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
const cleanupFns: Array<() => void> = []
|
||||
|
||||
if (type() === 'android') {
|
||||
try {
|
||||
await initMobileVpnService()
|
||||
console.error("easytier init vpn service done")
|
||||
} catch (e: any) {
|
||||
console.error("easytier init vpn service failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
cleanupFns.push(await listenGlobalEvents())
|
||||
currentMode.value = loadMode()
|
||||
initWithMode(currentMode.value);
|
||||
await initWithMode(currentMode.value);
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanupFns.forEach(unlisten => unlisten())
|
||||
})
|
||||
});
|
||||
|
||||
useTray(true)
|
||||
@@ -190,6 +218,12 @@ const remoteClient = computed(() => new GUIRemoteClient());
|
||||
const instanceId = ref<string | undefined>(undefined);
|
||||
const clientRunning = ref(false);
|
||||
|
||||
watch(instanceId, (newVal) => {
|
||||
if (newVal) {
|
||||
saveLastNetworkInstanceId(newVal);
|
||||
}
|
||||
});
|
||||
|
||||
watch(clientRunning, async (newVal, oldVal) => {
|
||||
if (!newVal && oldVal) {
|
||||
if (manualDisconnect.value) {
|
||||
@@ -197,6 +231,11 @@ watch(clientRunning, async (newVal, oldVal) => {
|
||||
return
|
||||
}
|
||||
await reconnectClient()
|
||||
} else if (newVal && !oldVal) {
|
||||
const lastInstanceId = loadLastNetworkInstanceId();
|
||||
if (lastInstanceId) {
|
||||
instanceId.value = lastInstanceId;
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -320,27 +359,11 @@ const setting_menu_items: Ref<MenuItem[]> = ref([
|
||||
},
|
||||
])
|
||||
|
||||
async function connectRpcClient(url?: string) {
|
||||
await initRpcConnection(url)
|
||||
console.log("easytier rpc connection established")
|
||||
async function connectRpcClient(isNormalMode: boolean, url?: string) {
|
||||
await initRpcConnection(isNormalMode, url)
|
||||
console.log("easytier rpc connection established, isNormalMode: ", isNormalMode)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (type() === 'android') {
|
||||
try {
|
||||
await initMobileVpnService()
|
||||
console.error("easytier init vpn service done")
|
||||
} catch (e: any) {
|
||||
console.error("easytier init vpn service failed", e)
|
||||
}
|
||||
}
|
||||
const unlisten = await listenGlobalEvents()
|
||||
|
||||
onUnmounted(() => {
|
||||
unlisten()
|
||||
})
|
||||
})
|
||||
|
||||
async function openConfigServerDialog() {
|
||||
editingMode.value = JSON.parse(JSON.stringify(loadMode()))
|
||||
configServerDialogVisible.value = true
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
name = "easytier-rpc-build"
|
||||
description = "Protobuf RPC Service Generator for EasyTier"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
edition.workspace = true
|
||||
homepage = "https://github.com/EasyTier/EasyTier"
|
||||
repository = "https://github.com/EasyTier/EasyTier"
|
||||
authors = ["kkrainbow"]
|
||||
keywords = ["vpn", "p2p", "network", "easytier"]
|
||||
categories = ["network-programming", "command-line-utilities"]
|
||||
rust-version = "1.89.0"
|
||||
license-file = "LICENSE"
|
||||
readme = "README.md"
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ impl prost_build::ServiceGenerator for ServiceGenerator {
|
||||
let method_descriptor_name = format!("{}MethodDescriptor", service.name);
|
||||
|
||||
let mut trait_methods = String::new();
|
||||
let mut weak_impl_methods = String::new();
|
||||
let mut enum_methods = String::new();
|
||||
let mut list_enum_methods = String::new();
|
||||
let mut client_methods = String::new();
|
||||
@@ -40,6 +41,8 @@ impl prost_build::ServiceGenerator for ServiceGenerator {
|
||||
let mut match_output_type_methods = String::new();
|
||||
let mut match_output_proto_type_methods = String::new();
|
||||
let mut match_handle_methods = String::new();
|
||||
// generate trait default method Xxx::json_call_method match branch
|
||||
let mut match_trait_json_methods = String::new();
|
||||
|
||||
let mut match_method_try_from = String::new();
|
||||
|
||||
@@ -66,6 +69,21 @@ impl prost_build::ServiceGenerator for ServiceGenerator {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
writeln!(
|
||||
weak_impl_methods,
|
||||
r#" async fn {method_name}(&self, ctrl: Self::Controller, input: {input_type}) -> {namespace}::error::Result<{output_type}> {{
|
||||
let Some(service) = self.upgrade() else {{
|
||||
return Err({namespace}::error::Error::Shutdown);
|
||||
}};
|
||||
service.{method_name}(ctrl, input).await
|
||||
}}"#,
|
||||
method_name = method.name,
|
||||
input_type = method.input_type,
|
||||
output_type = method.output_type,
|
||||
namespace = NAMESPACE,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
ServiceGenerator::write_comments(&mut enum_methods, 4, &method.comments).unwrap();
|
||||
writeln!(
|
||||
enum_methods,
|
||||
@@ -164,6 +182,22 @@ impl prost_build::ServiceGenerator for ServiceGenerator {
|
||||
namespace = NAMESPACE,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
write!(
|
||||
match_trait_json_methods,
|
||||
r#" "{name}" | "{proto_name}" => {{
|
||||
let req: {input_type} = ::serde_json::from_value(json).map_err(|e| {namespace}::error::Error::MalformatRpcPacket(format!("json error: {{}}", e)))?;
|
||||
let resp = self.{typed_method}(ctrl, req).await?;
|
||||
Ok(::serde_json::to_value(resp).map_err(|e| {namespace}::error::Error::MalformatRpcPacket(format!("json error: {{}}", e)))?)
|
||||
}}
|
||||
"#,
|
||||
name = method.name,
|
||||
proto_name = method.proto_name,
|
||||
input_type = method.input_type,
|
||||
typed_method = method.name,
|
||||
namespace = NAMESPACE,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
ServiceGenerator::write_comments(&mut buf, 0, &service.comments).unwrap();
|
||||
@@ -176,6 +210,29 @@ pub trait {name} {{
|
||||
type Controller: {namespace}::controller::Controller;
|
||||
|
||||
{trait_methods}
|
||||
|
||||
async fn json_call_method(
|
||||
&self,
|
||||
ctrl: Self::Controller,
|
||||
method_name: &str,
|
||||
json: ::serde_json::Value,
|
||||
) -> {namespace}::error::Result<::serde_json::Value> {{
|
||||
match method_name {{
|
||||
{match_trait_json_methods}
|
||||
_ => Err({namespace}::error::Error::InvalidMethodIndex(0, method_name.to_string())),
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl<T> {name} for ::std::sync::Weak<T>
|
||||
where
|
||||
T: Send + Sync + 'static,
|
||||
::std::sync::Arc<T>: {name},
|
||||
{{
|
||||
type Controller = <::std::sync::Arc<T> as {name}>::Controller;
|
||||
|
||||
{weak_impl_methods}
|
||||
}}
|
||||
|
||||
/// A service descriptor for a `{name}`.
|
||||
@@ -235,7 +292,7 @@ impl<C: {namespace}::controller::Controller> Clone for {client_name}Factory<C> {
|
||||
|
||||
impl<C> {namespace}::__rt::RpcClientFactory for {client_name}Factory<C> where C: {namespace}::controller::Controller {{
|
||||
type Descriptor = {descriptor_name};
|
||||
type ClientImpl = Box<dyn {name}<Controller = C> + Send + 'static>;
|
||||
type ClientImpl = Box<dyn {name}<Controller = C> + Send + Sync + 'static>;
|
||||
type Controller = C;
|
||||
|
||||
fn new(handler: impl {namespace}::handler::Handler<Descriptor = Self::Descriptor, Controller = Self::Controller>) -> Self::ClientImpl {{
|
||||
@@ -250,6 +307,16 @@ impl<C> {namespace}::__rt::RpcClientFactory for {client_name}Factory<C> where C:
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct {server_name}<A>(A) where A: {name} + Clone + Send + 'static;
|
||||
|
||||
impl<T> {server_name}<::std::sync::Weak<T>>
|
||||
where
|
||||
T: Send + Sync + 'static,
|
||||
::std::sync::Arc<T>: {name},
|
||||
{{
|
||||
pub fn new_arc(service: ::std::sync::Arc<T>) -> {server_name}<::std::sync::Weak<T>> {{
|
||||
{server_name}(::std::sync::Arc::downgrade(&service))
|
||||
}}
|
||||
}}
|
||||
|
||||
impl<A> {server_name}<A> where A: {name} + Clone + Send + 'static {{
|
||||
/// Creates a new server instance that dispatches all calls to the supplied service.
|
||||
pub fn new(service: A) -> {server_name}<A> {{
|
||||
@@ -345,6 +412,7 @@ impl {namespace}::descriptor::MethodDescriptor for {method_descriptor_name} {{
|
||||
proto_name = service.proto_name,
|
||||
package = service.package,
|
||||
trait_methods = trait_methods,
|
||||
weak_impl_methods = weak_impl_methods,
|
||||
enum_methods = enum_methods,
|
||||
list_enum_methods = list_enum_methods,
|
||||
client_own_methods = client_own_methods,
|
||||
@@ -356,6 +424,7 @@ impl {namespace}::descriptor::MethodDescriptor for {method_descriptor_name} {{
|
||||
match_output_type_methods = match_output_type_methods,
|
||||
match_output_proto_type_methods = match_output_proto_type_methods,
|
||||
match_handle_methods = match_handle_methods,
|
||||
match_trait_json_methods = match_trait_json_methods,
|
||||
namespace = NAMESPACE,
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
+10
-9
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "easytier-web"
|
||||
version = "2.5.0"
|
||||
edition = "2021"
|
||||
version = "2.6.0"
|
||||
edition.workspace = true
|
||||
description = "Config server for easytier. easytier-core gets config from this and web frontend use it as restful api server."
|
||||
|
||||
[dependencies]
|
||||
@@ -63,16 +63,17 @@ uuid = { version = "1.5.0", features = [
|
||||
] }
|
||||
|
||||
chrono = { version = "0.4.37", features = ["serde"] }
|
||||
openidconnect = { version = "4.0", default-features = false, features = ["accept-rfc3339-timestamps", "reqwest"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
subtle = "2.6"
|
||||
|
||||
mimalloc = { version = "*" }
|
||||
|
||||
[build-dependencies]
|
||||
thunk-rs = { git = "https://github.com/easytier/thunk.git", default-features = false, features = [
|
||||
"win7",
|
||||
] }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
embed = ["dep:axum-embed"]
|
||||
|
||||
# enable thunk-rs when compiling for x86_64 or i686 windows
|
||||
[target.x86_64-pc-windows-msvc.build-dependencies]
|
||||
thunk-rs = { git = "https://github.com/easytier/thunk.git", default-features = false, features = ["win7"] }
|
||||
|
||||
[target.i686-pc-windows-msvc.build-dependencies]
|
||||
thunk-rs = { git = "https://github.com/easytier/thunk.git", default-features = false, features = ["win7"] }
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use std::env;
|
||||
|
||||
fn main() {
|
||||
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||
let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
|
||||
// enable thunk-rs when target os is windows and arch is x86_64 or i686
|
||||
#[cfg(target_os = "windows")]
|
||||
if !std::env::var("TARGET")
|
||||
.unwrap_or_default()
|
||||
.contains("aarch64")
|
||||
{
|
||||
if target_os == "windows" && (target_arch == "x86" || target_arch == "x86_64") {
|
||||
thunk::thunk();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"dependencies": {
|
||||
"@primeuix/themes": "^1.2.3",
|
||||
"@vueuse/core": "^11.1.0",
|
||||
"axios": "^1.7.7",
|
||||
"axios": "^1.13.5",
|
||||
"chart.js": "^4.5.0",
|
||||
"floating-vue": "^5.2",
|
||||
"ip-num": "1.5.1",
|
||||
@@ -41,7 +41,7 @@
|
||||
"postcss-nested": "^7.0.2",
|
||||
"tailwindcss": "=3.4.17",
|
||||
"typescript": "~5.6.3",
|
||||
"vite": "^5.4.10",
|
||||
"vite": "^5.4.21",
|
||||
"vite-plugin-dts": "^4.3.0",
|
||||
"vue-tsc": "^2.1.10"
|
||||
},
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { AutoComplete, Button, Checkbox, Dialog, Divider, InputNumber, InputText, Panel, Password, SelectButton, ToggleButton } from 'primevue'
|
||||
import InputGroup from 'primevue/inputgroup'
|
||||
import InputGroupAddon from 'primevue/inputgroupaddon'
|
||||
import { SelectButton, Checkbox, InputText, InputNumber, AutoComplete, Panel, Divider, ToggleButton, Button, Password, Dialog } from 'primevue'
|
||||
import {
|
||||
addRow,
|
||||
DEFAULT_NETWORK_CONFIG,
|
||||
NetworkConfig,
|
||||
NetworkingMethod,
|
||||
normalizeNetworkConfig,
|
||||
removeRow
|
||||
} from '../types/network'
|
||||
import { defineProps, defineEmits, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AclManager from './acl/AclManager.vue'
|
||||
import UrlListInput from './UrlListInput.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
configInvalid?: boolean
|
||||
@@ -26,63 +28,18 @@ const curNetwork = defineModel('curNetwork', {
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const networking_methods = ref([
|
||||
{ value: NetworkingMethod.PublicServer, label: () => t('public_server') },
|
||||
{ value: NetworkingMethod.Manual, label: () => t('manual') },
|
||||
{ value: NetworkingMethod.Standalone, label: () => t('standalone') },
|
||||
])
|
||||
|
||||
const protos: { [proto: string]: number } = { tcp: 11010, udp: 11010, wg: 11011, ws: 11011, wss: 11012 }
|
||||
|
||||
function searchUrlSuggestions(e: { query: string }): string[] {
|
||||
const query = e.query
|
||||
const ret = []
|
||||
// if query match "^\w+:.*", then no proto prefix
|
||||
if (query.match(/^\w+:.*/)) {
|
||||
// if query is a valid url, then add to suggestions
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
new URL(query)
|
||||
ret.push(query)
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
else {
|
||||
for (const proto in protos) {
|
||||
let item = `${proto}://${query}`
|
||||
// if query match ":\d+$", then no port suffix
|
||||
if (!query.match(/:\d+$/)) {
|
||||
item += `:${protos[proto]}`
|
||||
}
|
||||
ret.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
const publicServerSuggestions = ref([''])
|
||||
|
||||
function searchPresetPublicServers(e: { query: string }) {
|
||||
const presetPublicServers = [
|
||||
'tcp://public.easytier.top:11010',
|
||||
]
|
||||
|
||||
const query = e.query
|
||||
// if query is sub string of presetPublicServers, add to suggestions
|
||||
let ret = presetPublicServers.filter(item => item.includes(query))
|
||||
// add additional suggestions
|
||||
if (query.length > 0) {
|
||||
ret = ret.concat(searchUrlSuggestions(e))
|
||||
}
|
||||
|
||||
publicServerSuggestions.value = ret
|
||||
}
|
||||
|
||||
const peerSuggestions = ref([''])
|
||||
|
||||
function searchPeerSuggestions(e: { query: string }) {
|
||||
peerSuggestions.value = searchUrlSuggestions(e)
|
||||
const protos: { [proto: string]: number } = {
|
||||
tcp: 11010,
|
||||
udp: 11010,
|
||||
wg: 11011,
|
||||
ws: 11011,
|
||||
wss: 11012,
|
||||
quic: 11012,
|
||||
faketcp: 11013,
|
||||
http: 80,
|
||||
https: 443,
|
||||
txt: 0,
|
||||
srv: 0,
|
||||
}
|
||||
|
||||
const inetSuggestions = ref([''])
|
||||
@@ -99,34 +56,6 @@ function searchInetSuggestions(e: { query: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
const listenerSuggestions = ref([''])
|
||||
|
||||
function searchListenerSuggestions(e: { query: string }) {
|
||||
const ret = []
|
||||
|
||||
for (const proto in protos) {
|
||||
let item = `${proto}://0.0.0.0:`
|
||||
// if query is a number, use it as port
|
||||
if (e.query.match(/^\d+$/)) {
|
||||
item += e.query
|
||||
}
|
||||
else {
|
||||
item += protos[proto]
|
||||
}
|
||||
|
||||
if (item.includes(e.query)) {
|
||||
ret.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
if (ret.length === 0) {
|
||||
ret.push(e.query)
|
||||
}
|
||||
|
||||
listenerSuggestions.value = ret
|
||||
}
|
||||
|
||||
|
||||
const exitNodesSuggestions = ref([''])
|
||||
|
||||
function searchExitNodesSuggestions(e: { query: string }) {
|
||||
@@ -158,10 +87,12 @@ const bool_flags: BoolFlag[] = [
|
||||
{ field: 'disable_quic_input', help: 'disable_quic_input_help' },
|
||||
{ field: 'disable_p2p', help: 'disable_p2p_help' },
|
||||
{ field: 'p2p_only', help: 'p2p_only_help' },
|
||||
{ field: 'lazy_p2p', help: 'lazy_p2p_help' },
|
||||
{ field: 'bind_device', help: 'bind_device_help' },
|
||||
{ field: 'no_tun', help: 'no_tun_help' },
|
||||
{ field: 'enable_exit_node', help: 'enable_exit_node_help' },
|
||||
{ field: 'relay_all_peer_rpc', help: 'relay_all_peer_rpc_help' },
|
||||
{ field: 'need_p2p', help: 'need_p2p_help' },
|
||||
{ field: 'multi_thread', help: 'multi_thread_help' },
|
||||
{ field: 'proxy_forward_by_system', help: 'proxy_forward_by_system_help' },
|
||||
{ field: 'disable_encryption', help: 'disable_encryption_help' },
|
||||
@@ -217,6 +148,16 @@ onMounted(() => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function syncNormalizedNetwork(network: NetworkConfig | undefined): void {
|
||||
if (!network) {
|
||||
return
|
||||
}
|
||||
|
||||
Object.assign(network, normalizeNetworkConfig(network))
|
||||
}
|
||||
|
||||
watch(() => curNetwork.value, syncNormalizedNetwork, { immediate: true, deep: false })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -263,17 +204,14 @@ onMounted(() => {
|
||||
|
||||
<div class="flex flex-row gap-x-9 flex-wrap">
|
||||
<div class="flex flex-col gap-2 basis-5/12 grow">
|
||||
<label for="nm">{{ t('networking_method') }}</label>
|
||||
<SelectButton v-model="curNetwork.networking_method" :options="networking_methods"
|
||||
:option-label="(v) => v.label()" option-value="value" />
|
||||
<div class="items-center flex flex-row p-fluid gap-x-1">
|
||||
<AutoComplete v-if="curNetwork.networking_method === NetworkingMethod.Manual" id="chips"
|
||||
v-model="curNetwork.peer_urls" :placeholder="t('chips_placeholder', ['tcp://8.8.8.8:11010'])"
|
||||
class="grow" multiple fluid :suggestions="peerSuggestions" @complete="searchPeerSuggestions" />
|
||||
|
||||
<AutoComplete v-if="curNetwork.networking_method === NetworkingMethod.PublicServer"
|
||||
v-model="curNetwork.public_server_url" :suggestions="publicServerSuggestions" class="grow"
|
||||
dropdown :complete-on-focus="false" @complete="searchPresetPublicServers" />
|
||||
<div class="flex items-center">
|
||||
<label for="initial_nodes">{{ t('initial_nodes') }}</label>
|
||||
<span class="pi pi-question-circle ml-2 self-center" v-tooltip="t('initial_nodes_help')"></span>
|
||||
</div>
|
||||
<div class="items-center flex flex-col p-fluid gap-y-2">
|
||||
<UrlListInput id="initial_nodes" v-model="curNetwork.peer_urls" :protos="protos"
|
||||
defaultUrl="tcp://:11010" :add-label="t('add_initial_node')"
|
||||
:placeholder="t('initial_node_placeholder')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -345,10 +283,8 @@ onMounted(() => {
|
||||
<div class="flex flex-row gap-x-9 flex-wrap">
|
||||
<div class="flex flex-col gap-2 grow p-fluid">
|
||||
<label for="listener_urls">{{ t('listener_urls') }}</label>
|
||||
<AutoComplete id="listener_urls" v-model="curNetwork.listener_urls" :suggestions="listenerSuggestions"
|
||||
class="w-full" dropdown :complete-on-focus="true"
|
||||
:placeholder="t('chips_placeholder', ['tcp://1.1.1.1:11010'])" multiple
|
||||
@complete="searchListenerSuggestions" />
|
||||
<UrlListInput v-model="curNetwork.listener_urls" :protos="protos" :add-label="t('add_listener_url')"
|
||||
placeholder="0.0.0.0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -371,6 +307,19 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row gap-x-9 flex-wrap">
|
||||
<div class="flex flex-col gap-2 basis-5/12 grow">
|
||||
<div class="flex">
|
||||
<label for="instance_recv_bps_limit">{{ t('instance_recv_bps_limit') }}</label>
|
||||
<span class="pi pi-question-circle ml-2 self-center"
|
||||
v-tooltip="t('instance_recv_bps_limit_help')"></span>
|
||||
</div>
|
||||
<InputNumber id="instance_recv_bps_limit" v-model="curNetwork.instance_recv_bps_limit"
|
||||
aria-describedby="instance_recv_bps_limit-help" :format="false"
|
||||
:placeholder="t('instance_recv_bps_limit_placeholder')" :min="1" fluid />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row gap-x-9 flex-wrap">
|
||||
<div class="flex flex-col gap-2 basis-5/12 grow">
|
||||
<div class="flex">
|
||||
@@ -443,9 +392,8 @@ onMounted(() => {
|
||||
<label for="mapped_listeners">{{ t('mapped_listeners') }}</label>
|
||||
<span class="pi pi-question-circle ml-2 self-center" v-tooltip="t('mapped_listeners_help')"></span>
|
||||
</div>
|
||||
<AutoComplete id="mapped_listeners" v-model="curNetwork.mapped_listeners"
|
||||
:placeholder="t('chips_placeholder', ['tcp://123.123.123.123:11223'])" class="w-full" multiple fluid
|
||||
:suggestions="peerSuggestions" @complete="searchPeerSuggestions" />
|
||||
<UrlListInput v-model="curNetwork.mapped_listeners" :protos="protos"
|
||||
:add-label="t('add_mapped_listener')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -541,6 +489,18 @@ onMounted(() => {
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Panel :header="t('acl.title')" toggleable collapsed>
|
||||
<div v-if="curNetwork.acl" class="flex flex-col gap-y-2">
|
||||
<AclManager v-model="curNetwork.acl" />
|
||||
</div>
|
||||
<div v-else class="flex justify-center p-4">
|
||||
<Button :label="t('acl.enabled')"
|
||||
@click="curNetwork.acl = { acl_v1: { chains: [], group: { declares: [], members: [] } } }" />
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<div class="flex pt-6 justify-center">
|
||||
<Button :label="t('run_network')" icon="pi pi-arrow-right" icon-pos="right" :disabled="configInvalid"
|
||||
@click="$emit('runNetwork', curNetwork)" />
|
||||
|
||||
@@ -206,27 +206,39 @@ const confirmDeleteNetwork = (event: any) => {
|
||||
});
|
||||
};
|
||||
|
||||
const saveAndRunNewNetwork = async () => {
|
||||
if (!currentNetworkConfig.value) {
|
||||
const saveAndRunNewNetwork = async (config?: NetworkTypes.NetworkConfig) => {
|
||||
const cfg = config ?? currentNetworkConfig.value;
|
||||
if (!cfg) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetInstanceId = instanceId.value ?? cfg.instance_id;
|
||||
if (targetInstanceId && cfg.instance_id !== targetInstanceId) {
|
||||
cfg.instance_id = targetInstanceId;
|
||||
}
|
||||
|
||||
try {
|
||||
await props.api.delete_network(instanceId.value!);
|
||||
let ret = await props.api.run_network(currentNetworkConfig.value, currentNetworkControl.remoteSave.value);
|
||||
console.debug("saveAndRunNewNetwork", ret);
|
||||
if (networkIsDisabled.value) {
|
||||
await props.api.save_config(cfg);
|
||||
await props.api.update_network_instance_state(cfg.instance_id, false);
|
||||
} else {
|
||||
await props.api.run_network(cfg, currentNetworkControl.remoteSave.value);
|
||||
}
|
||||
|
||||
delete networkMetaCache.value[currentNetworkConfig.value.instance_id];
|
||||
await loadNetworkMetas([currentNetworkConfig.value.instance_id]);
|
||||
delete networkMetaCache.value[cfg.instance_id];
|
||||
await loadNetworkMetas([cfg.instance_id]);
|
||||
|
||||
selectedInstanceId.value = { uuid: currentNetworkConfig.value.instance_id };
|
||||
selectedInstanceId.value = { uuid: cfg.instance_id };
|
||||
await loadNetworkInstanceIds();
|
||||
await loadCurrentNetworkInfo();
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to create network, error: ' + JSON.stringify(e.response.data), life: 2000 });
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to run network, error: ' + JSON.stringify(e.response?.data ?? e), life: 2000 });
|
||||
return;
|
||||
}
|
||||
|
||||
emits('update');
|
||||
// showCreateNetworkDialog.value = false;
|
||||
isEditingNetwork.value = false; // Exit creation mode after successful network creation
|
||||
isEditingNetwork.value = false;
|
||||
}
|
||||
|
||||
const saveNetworkConfig = async () => {
|
||||
@@ -388,18 +400,18 @@ const updateScreenWidth = () => {
|
||||
const menuRef = ref();
|
||||
const actionMenu: Ref<MenuItem[]> = ref([
|
||||
{
|
||||
label: t('web.device_management.edit_network'),
|
||||
label: () => t('web.device_management.edit_network'),
|
||||
icon: 'pi pi-pencil',
|
||||
visible: () => !(networkIsDisabled.value ?? true) && currentNetworkControl.editable.value,
|
||||
command: () => editNetwork()
|
||||
},
|
||||
{
|
||||
label: t('web.device_management.export_config'),
|
||||
label: () => t('web.device_management.export_config'),
|
||||
icon: 'pi pi-download',
|
||||
command: () => exportConfig()
|
||||
},
|
||||
{
|
||||
label: t('web.device_management.delete_network'),
|
||||
label: () => t('web.device_management.delete_network'),
|
||||
icon: 'pi pi-trash',
|
||||
class: 'p-error',
|
||||
visible: () => currentNetworkControl.deletable.value,
|
||||
@@ -539,13 +551,15 @@ onUnmounted(() => {
|
||||
:label="t('web.device_management.edit_as_file')" iconPos="left" severity="secondary" />
|
||||
<Button @click="importConfig" icon="pi pi-upload" :label="t('web.device_management.import_config')"
|
||||
iconPos="left" severity="help" />
|
||||
<Button v-if="networkIsDisabled" @click="saveNetworkConfig" icon="pi pi-save"
|
||||
:label="t('web.device_management.save_config')" iconPos="left" severity="success" />
|
||||
<Button v-if="networkIsDisabled" @click="saveNetworkConfig" :disabled="!currentNetworkConfig"
|
||||
icon="pi pi-save" :label="t('web.device_management.save_config')" iconPos="left"
|
||||
severity="success" />
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Config :cur-network="currentNetworkConfig" @run-network="saveAndRunNewNetwork"></Config>
|
||||
<Config :cur-network="currentNetworkConfig" :config-invalid="!currentNetworkConfig"
|
||||
@run-network="saveAndRunNewNetwork"></Config>
|
||||
</div>
|
||||
|
||||
<!-- Network Status (for running networks) -->
|
||||
|
||||
@@ -183,6 +183,12 @@ const myNodeInfoChips = computed(() => {
|
||||
if (!my_node_info)
|
||||
return chips
|
||||
|
||||
// peer id
|
||||
chips.push({
|
||||
label: `Peer ID: ${my_node_info.peer_id}`,
|
||||
icon: '',
|
||||
} as Chip)
|
||||
|
||||
// TUN Device Name
|
||||
const dev_name = props.curNetworkInst.detail?.dev_name
|
||||
if (dev_name) {
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
<script setup lang="ts">
|
||||
import { AutoComplete, Button, Dialog, InputNumber, InputText } from 'primevue'
|
||||
import InputGroup from 'primevue/inputgroup'
|
||||
import InputGroupAddon from 'primevue/inputgroupaddon'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
placeholder?: string
|
||||
protos: { [proto: string]: number }
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const url = defineModel<string>({ required: true })
|
||||
const editing = ref(false)
|
||||
const container = ref<HTMLElement | null>(null)
|
||||
const internalCompact = ref(false)
|
||||
const hostFocused = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
if (container.value) {
|
||||
const observer = new ResizeObserver(entries => {
|
||||
for (const entry of entries) {
|
||||
internalCompact.value = entry.contentRect.width < 400
|
||||
}
|
||||
})
|
||||
observer.observe(container.value)
|
||||
|
||||
onUnmounted(() => {
|
||||
observer.disconnect()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const parseUrl = (val: string | null | undefined) => {
|
||||
const getValidPort = (portStr: string, proto: string) => {
|
||||
const p = parseInt(portStr)
|
||||
return isNaN(p) ? (props.protos[proto] ?? 11010) : p
|
||||
}
|
||||
const parseByPattern = (input: string) => {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
const match = trimmed.match(/^(\w+):\/\/(.*)$/)
|
||||
const proto = match ? match[1] : 'tcp'
|
||||
const rest = match ? match[2] : trimmed
|
||||
const authority = rest.split(/[/?#]/)[0]
|
||||
if (!authority) {
|
||||
return null
|
||||
}
|
||||
const hostAndMaybePort = authority.includes('@') ? authority.slice(authority.lastIndexOf('@') + 1) : authority
|
||||
if (hostAndMaybePort.startsWith('[')) {
|
||||
const ipv6End = hostAndMaybePort.indexOf(']')
|
||||
if (ipv6End > 0) {
|
||||
const host = hostAndMaybePort.slice(0, ipv6End + 1)
|
||||
const remain = hostAndMaybePort.slice(ipv6End + 1)
|
||||
const port = remain.startsWith(':') ? getValidPort(remain.slice(1), proto) : (props.protos[proto] ?? 11010)
|
||||
return { proto, host, port }
|
||||
}
|
||||
}
|
||||
const portMatch = hostAndMaybePort.match(/^(.*):(\d+)$/)
|
||||
const host = portMatch ? portMatch[1] : hostAndMaybePort
|
||||
const port = portMatch ? parseInt(portMatch[2]) : (props.protos[proto] ?? 11010)
|
||||
return { proto, host, port }
|
||||
}
|
||||
|
||||
if (!val) {
|
||||
return { proto: 'tcp', host: '', port: props.protos['tcp'] ?? 11010 }
|
||||
}
|
||||
const parsedByPattern = parseByPattern(val)
|
||||
if (parsedByPattern) {
|
||||
return parsedByPattern
|
||||
}
|
||||
return { proto: 'tcp', host: '', port: 11010 }
|
||||
}
|
||||
|
||||
const internalValue = ref(parseUrl(url.value))
|
||||
const defaultHost = '0.0.0.0'
|
||||
|
||||
const buildUrlValue = (value: { proto: string, host: string, port: number }, forceDefaultHost = false) => {
|
||||
const proto = value.proto || 'tcp'
|
||||
const rawHost = (value.host ?? '').trim()
|
||||
const host = rawHost || (forceDefaultHost ? defaultHost : '')
|
||||
if (!host) {
|
||||
return null
|
||||
}
|
||||
let port = value.port
|
||||
if (isNaN(parseInt(port as any))) {
|
||||
port = props.protos[proto] ?? 11010
|
||||
}
|
||||
|
||||
if (props.protos[proto] === 0) {
|
||||
return `${proto}://${host}`
|
||||
}
|
||||
return `${proto}://${host}:${port}`
|
||||
}
|
||||
|
||||
const syncUrlFromInternal = (forceDefaultHost = false) => {
|
||||
const nextUrl = buildUrlValue(internalValue.value, forceDefaultHost)
|
||||
if (!nextUrl || nextUrl === url.value) {
|
||||
return
|
||||
}
|
||||
url.value = nextUrl
|
||||
}
|
||||
|
||||
const onHostBlur = () => {
|
||||
hostFocused.value = false
|
||||
syncUrlFromInternal(true)
|
||||
}
|
||||
|
||||
const onHostFocus = () => {
|
||||
hostFocused.value = true
|
||||
}
|
||||
|
||||
const onDialogConfirm = () => {
|
||||
syncUrlFromInternal(true)
|
||||
editing.value = false
|
||||
}
|
||||
|
||||
const isNoPortProto = computed(() => {
|
||||
return props.protos[internalValue.value.proto] === 0
|
||||
})
|
||||
|
||||
// Sync from external
|
||||
watch(() => url.value, (newVal) => {
|
||||
if (hostFocused.value) {
|
||||
return
|
||||
}
|
||||
const parsed = parseUrl(newVal)
|
||||
const internalHost = internalValue.value.host ?? ''
|
||||
const sameHost = parsed.host === internalHost || (!internalHost.trim() && parsed.host === defaultHost)
|
||||
if (parsed.proto !== internalValue.value.proto ||
|
||||
!sameHost ||
|
||||
parsed.port !== internalValue.value.port) {
|
||||
internalValue.value = parsed
|
||||
}
|
||||
})
|
||||
|
||||
// Sync to external
|
||||
watch(internalValue, () => {
|
||||
syncUrlFromInternal(false)
|
||||
}, { deep: true })
|
||||
|
||||
const protoOptions = computed(() => Object.keys(props.protos))
|
||||
const filteredProtos = ref<string[]>([])
|
||||
|
||||
const searchProtos = (event: { query: string }) => {
|
||||
if (!event.query.trim().length) {
|
||||
filteredProtos.value = [...protoOptions.value]
|
||||
} else {
|
||||
filteredProtos.value = protoOptions.value.filter((proto) => {
|
||||
return proto.toLowerCase().startsWith(event.query.toLowerCase())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const onProtoChange = (newProto: string) => {
|
||||
const oldProto = internalValue.value.proto
|
||||
const oldDefault = props.protos[oldProto]
|
||||
const newDefault = props.protos[newProto]
|
||||
|
||||
if (oldDefault !== undefined && internalValue.value.port === oldDefault && newDefault !== undefined) {
|
||||
internalValue.value.port = newDefault
|
||||
}
|
||||
internalValue.value.proto = newProto
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="container" class="w-full">
|
||||
<InputGroup v-if="!internalCompact" class="w-full">
|
||||
<AutoComplete :model-value="internalValue.proto" :suggestions="filteredProtos" dropdown
|
||||
class="max-w-32 proto-autocomplete-in-group" @complete="searchProtos"
|
||||
@update:model-value="onProtoChange" />
|
||||
<InputText v-model="internalValue.host" :placeholder="placeholder || '0.0.0.0'" class="grow"
|
||||
@focus="onHostFocus" @blur="onHostBlur" />
|
||||
<template v-if="!isNoPortProto">
|
||||
<InputGroupAddon>
|
||||
<span style="font-weight: bold">:</span>
|
||||
</InputGroupAddon>
|
||||
<InputNumber v-model="internalValue.port" :format="false" :min="1" :max="65535" class="max-w-24"
|
||||
fluid />
|
||||
</template>
|
||||
<slot name="actions"></slot>
|
||||
</InputGroup>
|
||||
|
||||
<div v-else class="flex justify-between items-center p-2 border rounded w-full">
|
||||
<span class="truncate mr-2">{{ url }}</span>
|
||||
<div class="flex items-center">
|
||||
<Button icon="pi pi-pencil" class="p-button-sm p-button-text" @click="editing = true" />
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog v-model:visible="editing" modal :header="placeholder" :style="{ width: '90vw', maxWidth: '500px' }">
|
||||
<div class="flex flex-col gap-4 py-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label>{{ t('tunnel_proto') }}</label>
|
||||
<AutoComplete :model-value="internalValue.proto" :suggestions="filteredProtos" dropdown fluid
|
||||
@complete="searchProtos" @update:model-value="onProtoChange" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label>{{ t('web.common.address') || 'Address' }}</label>
|
||||
<InputText v-model="internalValue.host" :placeholder="placeholder || '0.0.0.0'" class="w-full"
|
||||
@focus="onHostFocus" @blur="onHostBlur" />
|
||||
</div>
|
||||
<div v-if="!isNoPortProto" class="flex flex-col gap-2">
|
||||
<label>{{ t('port') }}</label>
|
||||
<InputNumber v-model="internalValue.port" :format="false" :min="1" :max="65535" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button :label="t('web.common.confirm') || 'Done'" icon="pi pi-check" @click="onDialogConfirm"
|
||||
autofocus />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.proto-autocomplete-in-group,
|
||||
.proto-autocomplete-in-group :deep(.p-autocomplete-input),
|
||||
.proto-autocomplete-in-group :deep(.p-autocomplete-dropdown) {
|
||||
border-top-right-radius: 0 !important;
|
||||
border-bottom-right-radius: 0 !important;
|
||||
}
|
||||
|
||||
.proto-autocomplete-in-group :deep(.p-autocomplete-dropdown) {
|
||||
border-right: 0 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { Button } from 'primevue'
|
||||
import UrlInput from './UrlInput.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
protos: { [proto: string]: number }
|
||||
addLabel: string
|
||||
placeholder?: string
|
||||
defaultUrl?: string
|
||||
}>()
|
||||
|
||||
const list = defineModel<string[]>({ required: true })
|
||||
|
||||
const addUrl = () => {
|
||||
list.value.push(props.defaultUrl || 'tcp://0.0.0.0:11010')
|
||||
}
|
||||
|
||||
const removeUrl = (index: number) => {
|
||||
list.value.splice(index, 1)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-y-2 w-full">
|
||||
<div v-for="(_, index) in list" :key="index" class="flex gap-2 items-center w-full">
|
||||
<UrlInput v-model="list[index]" :protos="protos" :placeholder="placeholder">
|
||||
<template #actions>
|
||||
<Button icon="pi pi-trash" severity="danger" text rounded @click="removeUrl(index)" />
|
||||
</template>
|
||||
</UrlInput>
|
||||
</div>
|
||||
<div class="flex justify-center items-center w-full h-10 border-2 border-dashed border-surface-300 dark:border-surface-600 rounded-lg cursor-pointer hover:border-primary hover:bg-surface-50 dark:hover:bg-surface-800 transition-colors duration-200 gap-2 text-surface-500 dark:text-surface-400"
|
||||
@click="addUrl">
|
||||
<i class="pi pi-plus text-sm"></i>
|
||||
<span class="text-sm font-medium">{{ addLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,218 @@
|
||||
<script setup lang="ts">
|
||||
import { Button, Column, DataTable, Divider, InputText, Select, SelectButton, ToggleButton } from 'primevue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AclAction, AclChain, AclChainType, AclProtocol, AclRule } from '../../types/network'
|
||||
import AclRuleDialog from './AclRuleDialog.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
groupNames?: string[]
|
||||
}>()
|
||||
|
||||
const chain = defineModel<AclChain>({ required: true })
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
watch(() => chain.value.rules, (newRules) => {
|
||||
if (!newRules) return
|
||||
const isSorted = newRules.every((rule, i) => i === 0 || (rule.priority || 0) <= (newRules[i - 1].priority || 0))
|
||||
if (!isSorted) {
|
||||
chain.value.rules.sort((a, b) => (b.priority || 0) - (a.priority || 0))
|
||||
}
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
const actionOptions = [
|
||||
{ label: () => t('acl.allow'), value: AclAction.Allow },
|
||||
{ label: () => t('acl.drop'), value: AclAction.Drop },
|
||||
]
|
||||
|
||||
const chainTypeOptions = [
|
||||
{ label: () => t('acl.inbound'), value: AclChainType.Inbound },
|
||||
{ label: () => t('acl.outbound'), value: AclChainType.Outbound },
|
||||
{ label: () => t('acl.forward'), value: AclChainType.Forward },
|
||||
]
|
||||
|
||||
const editingRule = ref<AclRule | null>(null)
|
||||
const editingRuleIndex = ref(-1)
|
||||
const showRuleDialog = ref(false)
|
||||
|
||||
function getProtocolLabel(proto: AclProtocol) {
|
||||
switch (proto) {
|
||||
case AclProtocol.Any: return t('acl.any')
|
||||
case AclProtocol.TCP: return 'TCP'
|
||||
case AclProtocol.UDP: return 'UDP'
|
||||
case AclProtocol.ICMP: return 'ICMP'
|
||||
case AclProtocol.ICMPv6: return 'ICMPv6'
|
||||
default: return t('event.Unknown')
|
||||
}
|
||||
}
|
||||
|
||||
function getActionLabel(action: AclAction) {
|
||||
switch (action) {
|
||||
case AclAction.Allow: return t('acl.allow')
|
||||
case AclAction.Drop: return t('acl.drop')
|
||||
default: return t('event.Unknown')
|
||||
}
|
||||
}
|
||||
|
||||
function addRule() {
|
||||
editingRuleIndex.value = -1
|
||||
editingRule.value = {
|
||||
name: '',
|
||||
description: '',
|
||||
priority: chain.value.rules.length,
|
||||
enabled: true,
|
||||
protocol: AclProtocol.Any,
|
||||
ports: [],
|
||||
source_ips: [],
|
||||
destination_ips: [],
|
||||
source_ports: [],
|
||||
action: AclAction.Allow,
|
||||
rate_limit: 0,
|
||||
burst_limit: 0,
|
||||
stateful: false,
|
||||
source_groups: [],
|
||||
destination_groups: [],
|
||||
}
|
||||
showRuleDialog.value = true
|
||||
}
|
||||
|
||||
function editRule(index: number) {
|
||||
editingRuleIndex.value = index
|
||||
editingRule.value = JSON.parse(JSON.stringify(chain.value.rules[index]))
|
||||
showRuleDialog.value = true
|
||||
}
|
||||
|
||||
function deleteRule(index: number) {
|
||||
chain.value.rules.splice(index, 1)
|
||||
}
|
||||
|
||||
function saveRule(rule: AclRule) {
|
||||
if (editingRuleIndex.value === -1) {
|
||||
chain.value.rules.push(rule)
|
||||
} else {
|
||||
chain.value.rules[editingRuleIndex.value] = rule
|
||||
}
|
||||
chain.value.rules.sort((a, b) => (b.priority || 0) - (a.priority || 0))
|
||||
}
|
||||
|
||||
function onRowReorder(event: any) {
|
||||
chain.value.rules = event.value
|
||||
// Update priorities based on new order (higher priority at top)
|
||||
chain.value.rules.forEach((rule, index) => {
|
||||
rule.priority = chain.value.rules.length - index - 1
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<!-- Chain Metadata Section -->
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-4 p-4 bg-gray-50 rounded-lg border border-gray-200 dark:bg-gray-900 dark:border-gray-700">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="font-bold text-sm">{{ t('acl.chain.name') }}</label>
|
||||
<InputText v-model="chain.name" size="small" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="font-bold text-sm">{{ t('acl.rule.description') }}</label>
|
||||
<InputText v-model="chain.description" size="small" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-6 col-span-full border-t pt-2 mt-2 dark:border-gray-700">
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="font-bold text-sm">{{ t('acl.rule.enabled') }}</label>
|
||||
<ToggleButton v-model="chain.enabled" on-icon="pi pi-check" off-icon="pi pi-times"
|
||||
:on-label="t('web.common.enable')" :off-label="t('web.common.disable')" class="w-24" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="font-bold text-sm">{{ t('acl.chain.type') }}</label>
|
||||
<Select v-model="chain.chain_type" :options="chainTypeOptions" :option-label="opt => opt.label()"
|
||||
option-value="value" size="small" class="w-40" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2 ml-auto">
|
||||
<label class="font-bold text-sm">{{ t('acl.default_action') }}</label>
|
||||
<SelectButton v-model="chain.default_action" :options="actionOptions" :option-label="opt => opt.label()"
|
||||
option-value="value" :allow-empty="false" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row items-center gap-4 justify-between">
|
||||
<h4 class="text-md font-bold">{{ t('acl.rules') }}</h4>
|
||||
<Button icon="pi pi-plus" :label="t('acl.add_rule')" severity="success" size="small" @click="addRule" />
|
||||
</div>
|
||||
|
||||
<DataTable :value="chain.rules" @row-reorder="onRowReorder" responsiveLayout="scroll">
|
||||
<Column rowReorder headerStyle="width: 3rem" />
|
||||
<Column field="enabled" :header="t('acl.rule.enabled')">
|
||||
<template #body="{ data }">
|
||||
<i class="pi" :class="data.enabled ? 'pi-check-circle text-green-500' : 'pi-times-circle text-red-500'"></i>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="name" :header="t('acl.rule.name')" />
|
||||
<Column :header="t('acl.match')">
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-col gap-2 py-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="px-2 py-0.5 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 rounded-md text-[10px] font-bold uppercase tracking-wider">
|
||||
{{ getProtocolLabel(data.protocol) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-3">
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
<span class="text-[10px] font-bold text-gray-400 uppercase w-7">Src</span>
|
||||
<div class="flex flex-wrap gap-1 items-center overflow-hidden">
|
||||
<span v-for="ip in data.source_ips" :key="ip"
|
||||
class="font-mono text-xs bg-surface-100 dark:bg-surface-800 px-1.5 py-0.5 rounded">{{ ip }}</span>
|
||||
<span v-for="grp in data.source_groups" :key="grp"
|
||||
class="text-xs font-bold text-purple-600 dark:text-purple-400">@{{ grp }}</span>
|
||||
<span v-if="data.source_ports.length" class="text-xs text-blue-600 dark:text-blue-400 font-mono">:{{
|
||||
data.source_ports.join(',') }}</span>
|
||||
<span v-if="!data.source_ips.length && !data.source_groups.length" class="text-gray-400">*</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<i class="pi pi-arrow-right hidden sm:block text-gray-300 text-xs"></i>
|
||||
<Divider layout="horizontal" class="sm:hidden my-1" />
|
||||
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
<span class="text-[10px] font-bold text-gray-400 uppercase w-7">Dst</span>
|
||||
<div class="flex flex-wrap gap-1 items-center overflow-hidden">
|
||||
<span v-for="ip in data.destination_ips" :key="ip"
|
||||
class="font-mono text-xs bg-surface-100 dark:bg-surface-800 px-1.5 py-0.5 rounded">{{ ip }}</span>
|
||||
<span v-for="grp in data.destination_groups" :key="grp"
|
||||
class="text-xs font-bold text-purple-600 dark:text-purple-400">@{{ grp }}</span>
|
||||
<span v-if="data.ports.length" class="text-xs text-blue-600 dark:text-blue-400 font-mono">:{{
|
||||
data.ports.join(',') }}</span>
|
||||
<span v-if="!data.destination_ips.length && !data.destination_groups.length"
|
||||
class="text-gray-400">*</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="action" :header="t('acl.rule.action')">
|
||||
<template #body="{ data }">
|
||||
<span :class="data.action === AclAction.Allow ? 'text-green-600' : 'text-red-600 font-bold'">
|
||||
{{ getActionLabel(data.action) }}
|
||||
</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column :header="t('web.common.edit')">
|
||||
<template #body="{ index }">
|
||||
<div class="flex gap-2">
|
||||
<Button icon="pi pi-pencil" text rounded @click="editRule(index)" />
|
||||
<Button icon="pi pi-trash" severity="danger" text rounded @click="deleteRule(index)" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
|
||||
<AclRuleDialog v-if="showRuleDialog && editingRule" v-model:visible="showRuleDialog" v-model:rule="editingRule"
|
||||
:group-names="props.groupNames" @save="saveRule" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { Button, Column, DataTable, Dialog, InputText, MultiSelect, Password } from 'primevue';
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { GroupIdentity, GroupInfo } from '../../types/network';
|
||||
|
||||
const props = defineProps<{
|
||||
groupNames?: string[]
|
||||
}>()
|
||||
|
||||
const group = defineModel<GroupInfo>({ required: true })
|
||||
const emit = defineEmits(['rename-group'])
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const editingGroup = ref<GroupIdentity | null>(null)
|
||||
const editingGroupIndex = ref(-1)
|
||||
const showGroupDialog = ref(false)
|
||||
const oldGroupName = ref('')
|
||||
|
||||
function addGroup() {
|
||||
editingGroupIndex.value = -1
|
||||
editingGroup.value = {
|
||||
group_name: '',
|
||||
group_secret: '',
|
||||
}
|
||||
oldGroupName.value = ''
|
||||
showGroupDialog.value = true
|
||||
}
|
||||
|
||||
function editGroup(index: number) {
|
||||
editingGroupIndex.value = index
|
||||
editingGroup.value = JSON.parse(JSON.stringify(group.value.declares[index]))
|
||||
oldGroupName.value = editingGroup.value?.group_name || ''
|
||||
showGroupDialog.value = true
|
||||
}
|
||||
|
||||
function deleteGroup(index: number) {
|
||||
group.value.declares.splice(index, 1)
|
||||
}
|
||||
|
||||
function saveGroup() {
|
||||
if (!editingGroup.value) return
|
||||
const newName = editingGroup.value.group_name
|
||||
|
||||
if (editingGroupIndex.value === -1) {
|
||||
group.value.declares.push(editingGroup.value)
|
||||
} else {
|
||||
if (oldGroupName.value && oldGroupName.value !== newName) {
|
||||
// Sync in members
|
||||
group.value.members = group.value.members.map(m => m === oldGroupName.value ? newName : m)
|
||||
// Notify parent to sync in rules
|
||||
emit('rename-group', { oldName: oldGroupName.value, newName })
|
||||
}
|
||||
group.value.declares[editingGroupIndex.value] = editingGroup.value
|
||||
}
|
||||
showGroupDialog.value = false
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex flex-col">
|
||||
<label class="font-bold text-lg">{{ t('acl.group.declares') }}</label>
|
||||
<small class="text-gray-500">{{ t('acl.group.help') }}</small>
|
||||
</div>
|
||||
<Button icon="pi pi-plus" :label="t('web.common.add')" severity="success" @click="addGroup" />
|
||||
</div>
|
||||
|
||||
<DataTable :value="group.declares" responsiveLayout="scroll">
|
||||
<Column field="group_name" :header="t('acl.group.name')" />
|
||||
<Column field="group_secret" :header="t('acl.group.secret')">
|
||||
<template #body="{ data }">
|
||||
<Password v-model="data.group_secret" :feedback="false" toggleMask readonly plain class="w-full" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column :header="t('web.common.edit')" headerStyle="width: 8rem">
|
||||
<template #body="{ index }">
|
||||
<div class="flex gap-2">
|
||||
<Button icon="pi pi-pencil" text rounded @click="editGroup(index)" />
|
||||
<Button icon="pi pi-trash" severity="danger" text rounded @click="deleteGroup(index)" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="font-bold text-lg">{{ t('acl.group.members') }}</label>
|
||||
<MultiSelect v-model="group.members" :options="props.groupNames" multiple fluid filter
|
||||
:placeholder="t('acl.group.members')" />
|
||||
</div>
|
||||
|
||||
<!-- Group Identity Dialog -->
|
||||
<Dialog v-model:visible="showGroupDialog" modal :header="t('acl.groups')" :style="{ width: '400px' }">
|
||||
<div v-if="editingGroup" class="flex flex-col gap-4 pt-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="font-bold">{{ t('acl.group.name') }}</label>
|
||||
<InputText v-model="editingGroup.group_name" fluid />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="font-bold">{{ t('acl.group.secret') }}</label>
|
||||
<Password v-model="editingGroup.group_secret" :feedback="false" toggleMask fluid />
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button :label="t('web.common.cancel')" icon="pi pi-times" @click="showGroupDialog = false" text />
|
||||
<Button :label="t('web.common.save')" icon="pi pi-save" @click="saveGroup" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,150 @@
|
||||
<script setup lang="ts">
|
||||
import { Button, Menu, Tab, TabList, TabPanel, TabPanels, Tabs } from 'primevue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Acl, AclAction, AclChainType } from '../../types/network'
|
||||
import AclChainEditor from './AclChainEditor.vue'
|
||||
import AclGroupEditor from './AclGroupEditor.vue'
|
||||
|
||||
const acl = defineModel<Acl>({ required: true })
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const activeTab = ref(0)
|
||||
const menu = ref()
|
||||
|
||||
const addMenuModel = ref([
|
||||
{ label: () => t('acl.inbound'), command: () => addChain(AclChainType.Inbound) },
|
||||
{ label: () => t('acl.outbound'), command: () => addChain(AclChainType.Outbound) },
|
||||
{ label: () => t('acl.forward'), command: () => addChain(AclChainType.Forward) },
|
||||
])
|
||||
|
||||
function addChain(type: AclChainType) {
|
||||
if (!acl.value.acl_v1) {
|
||||
acl.value.acl_v1 = { chains: [], group: { declares: [], members: [] } }
|
||||
}
|
||||
|
||||
let defaultName = ''
|
||||
switch (type) {
|
||||
case AclChainType.Inbound: defaultName = 'Inbound'; break;
|
||||
case AclChainType.Outbound: defaultName = 'Outbound'; break;
|
||||
case AclChainType.Forward: defaultName = 'Forward'; break;
|
||||
}
|
||||
|
||||
acl.value.acl_v1.chains.push({
|
||||
name: defaultName,
|
||||
chain_type: type,
|
||||
description: '',
|
||||
enabled: true,
|
||||
rules: [],
|
||||
default_action: AclAction.Allow
|
||||
})
|
||||
|
||||
activeTab.value = acl.value.acl_v1.chains.length - 1
|
||||
}
|
||||
|
||||
function removeChain(index: number) {
|
||||
if (confirm(t('acl.delete_chain_confirm'))) {
|
||||
acl.value.acl_v1?.chains.splice(index, 1)
|
||||
if (activeTab.value >= (acl.value.acl_v1?.chains.length || 0)) {
|
||||
activeTab.value = Math.max(0, (acl.value.acl_v1?.chains.length || 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleRenameGroup({ oldName, newName }: { oldName: string, newName: string }) {
|
||||
if (!acl.value.acl_v1) return
|
||||
acl.value.acl_v1.chains.forEach(chain => {
|
||||
chain.rules.forEach(rule => {
|
||||
rule.source_groups = rule.source_groups.map(g => g === oldName ? newName : g)
|
||||
rule.destination_groups = rule.destination_groups.map(g => g === oldName ? newName : g)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const groupNames = computed(() => {
|
||||
return acl.value.acl_v1?.group?.declares.map(g => g.group_name) || []
|
||||
})
|
||||
|
||||
const tabs = computed(() => {
|
||||
const chains = acl.value.acl_v1?.chains || []
|
||||
const result: { type: string, label: string, index: number }[] = []
|
||||
|
||||
if (chains.length === 0) {
|
||||
result.push({ type: 'empty', label: t('acl.chains'), index: 0 })
|
||||
}
|
||||
else {
|
||||
chains.forEach((c, index) => {
|
||||
result.push({
|
||||
type: 'chain',
|
||||
label: c.name || `Chain ${index}`,
|
||||
index
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
result.push({ type: 'groups', label: t('acl.groups'), index: result.length })
|
||||
return result
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<Tabs v-model:value="activeTab">
|
||||
<div class="flex items-center border-b border-surface-200 dark:border-surface-700">
|
||||
<TabList class="flex-grow min-w-0 overflow-x-auto" style="border-bottom: none;">
|
||||
<Tab v-for="tab in tabs" :key="tab.type + tab.index" :value="tab.index">
|
||||
<div class="flex items-center gap-2 whitespace-nowrap">
|
||||
{{ tab.label }}
|
||||
<Button v-if="tab.type === 'chain'" icon="pi pi-times" severity="danger" text rounded size="small"
|
||||
class="w-6 h-6 p-0" @click.stop="removeChain(tab.index)" />
|
||||
</div>
|
||||
</Tab>
|
||||
</TabList>
|
||||
<div
|
||||
class="flex-shrink-0 flex items-center px-2 bg-white dark:bg-gray-900 border-l border-surface-100 dark:border-surface-800">
|
||||
<Button icon="pi pi-plus" text rounded size="small" class="w-8 h-8 p-0"
|
||||
@click="(event) => menu.toggle(event)" />
|
||||
<Menu ref="menu" :model="addMenuModel" :popup="true" />
|
||||
</div>
|
||||
</div>
|
||||
<TabPanels>
|
||||
<TabPanel v-for="tab in tabs" :key="'panel' + tab.type + tab.index" :value="tab.index">
|
||||
<!-- Empty State within TabPanel -->
|
||||
<div v-if="tab.type === 'empty'"
|
||||
class="py-8 flex flex-col items-center justify-center border-2 border-dashed border-surface-200 rounded-lg bg-surface-50 dark:bg-surface-900 dark:border-surface-700">
|
||||
<i class="pi pi-shield text-5xl mb-4 text-primary" />
|
||||
<div class="text-xl font-bold mb-2">{{ t('acl.chains') }}</div>
|
||||
<p class="text-surface-500 mb-8 text-center max-w-sm px-4">{{ t('acl.help') }}</p>
|
||||
<div class="flex flex-wrap gap-3 justify-center">
|
||||
<Button :label="t('acl.inbound')" icon="pi pi-arrow-down-left" @click="addChain(AclChainType.Inbound)" />
|
||||
<Button :label="t('acl.outbound')" icon="pi pi-arrow-up-right" @click="addChain(AclChainType.Outbound)" />
|
||||
<Button :label="t('acl.forward')" icon="pi pi-directions" @click="addChain(AclChainType.Forward)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rule Chains -->
|
||||
<div v-if="tab.type === 'chain' && acl.acl_v1 && acl.acl_v1.chains[tab.index]" class="py-4">
|
||||
<AclChainEditor v-model="acl.acl_v1.chains[tab.index]" :group-names="groupNames" />
|
||||
</div>
|
||||
|
||||
<!-- Group Management -->
|
||||
<div v-if="tab.type === 'groups'" class="py-4">
|
||||
<template v-if="acl.acl_v1">
|
||||
<AclGroupEditor v-if="acl.acl_v1.group" v-model="acl.acl_v1.group" :group-names="groupNames"
|
||||
@rename-group="handleRenameGroup" />
|
||||
<div v-else class="flex justify-center p-4">
|
||||
<Button :label="t('web.common.add') + ' ' + t('acl.groups')"
|
||||
@click="acl.acl_v1.group = { declares: [], members: [] }" />
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="flex justify-center p-4">
|
||||
<Button :label="t('acl.enabled')"
|
||||
@click="acl.acl_v1 = { chains: [], group: { declares: [], members: [] } }" />
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,150 @@
|
||||
<script setup lang="ts">
|
||||
import { AutoComplete, Button, Checkbox, Dialog, InputNumber, InputText, MultiSelect, Panel, SelectButton, ToggleButton } from 'primevue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { AclAction, AclProtocol, AclRule } from '../../types/network';
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
groupNames?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:visible', 'save'])
|
||||
|
||||
const rule = defineModel<AclRule>('rule', { required: true })
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const protocolOptions = [
|
||||
{ label: () => t('acl.any'), value: AclProtocol.Any },
|
||||
{ label: 'TCP', value: AclProtocol.TCP },
|
||||
{ label: 'UDP', value: AclProtocol.UDP },
|
||||
{ label: 'ICMP', value: AclProtocol.ICMP },
|
||||
{ label: 'ICMPv6', value: AclProtocol.ICMPv6 },
|
||||
]
|
||||
|
||||
const actionOptions = [
|
||||
{ label: () => t('acl.allow'), value: AclAction.Allow },
|
||||
{ label: () => t('acl.drop'), value: AclAction.Drop },
|
||||
]
|
||||
|
||||
const showPorts = computed(() => {
|
||||
return rule.value.protocol === AclProtocol.TCP || rule.value.protocol === AclProtocol.UDP || rule.value.protocol === AclProtocol.Any
|
||||
})
|
||||
|
||||
function close() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
function save() {
|
||||
emit('save', rule.value)
|
||||
close()
|
||||
}
|
||||
|
||||
// Suggestions for IP/Port AutoComplete
|
||||
const genericSuggestions = ref<string[]>([])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :visible="visible" @update:visible="emit('update:visible', $event)" modal :header="t('acl.edit_rule')"
|
||||
:style="{ width: '90vw', maxWidth: '600px' }">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-row gap-4 items-center">
|
||||
<div class="flex flex-col gap-2 grow">
|
||||
<label class="font-bold">{{ t('acl.rule.name') }}</label>
|
||||
<InputText v-model="rule.name" fluid />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="font-bold">{{ t('acl.rule.enabled') }}</label>
|
||||
<ToggleButton v-model="rule.enabled" on-icon="pi pi-check" off-icon="pi pi-times"
|
||||
:on-label="t('web.common.enable')" :off-label="t('web.common.disable')" class="w-24" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="font-bold">{{ t('acl.rule.description') }}</label>
|
||||
<InputText v-model="rule.description" fluid />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row gap-4 flex-wrap">
|
||||
<div class="flex flex-col gap-2 grow">
|
||||
<label class="font-bold">{{ t('acl.rule.action') }}</label>
|
||||
<SelectButton v-model="rule.action" :options="actionOptions" :option-label="opt => opt.label()"
|
||||
option-value="value" :allow-empty="false" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 grow">
|
||||
<label class="font-bold">{{ t('acl.rule.protocol') }}</label>
|
||||
<SelectButton v-model="rule.protocol" :options="protocolOptions"
|
||||
:option-label="opt => typeof opt.label === 'function' ? opt.label() : opt.label" option-value="value"
|
||||
:allow-empty="false" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Panel :header="t('acl.rules')" toggleable>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="font-bold">{{ t('acl.rule.src_ips') }}</label>
|
||||
<AutoComplete v-model="rule.source_ips" multiple fluid :suggestions="genericSuggestions"
|
||||
@complete="genericSuggestions = [$event.query]"
|
||||
:placeholder="t('chips_placeholder', ['10.126.126.0/24'])" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="font-bold">{{ t('acl.rule.dst_ips') }}</label>
|
||||
<AutoComplete v-model="rule.destination_ips" multiple fluid :suggestions="genericSuggestions"
|
||||
@complete="genericSuggestions = [$event.query]"
|
||||
:placeholder="t('chips_placeholder', ['10.126.126.2/32'])" />
|
||||
</div>
|
||||
|
||||
<div v-if="showPorts" class="flex flex-row gap-4 flex-wrap">
|
||||
<div class="flex flex-col gap-2 grow">
|
||||
<label class="font-bold">{{ t('acl.rule.src_ports') }}</label>
|
||||
<AutoComplete v-model="rule.source_ports" multiple fluid :suggestions="genericSuggestions"
|
||||
@complete="genericSuggestions = [$event.query]" placeholder="e.g. 80, 1000-2000" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 grow">
|
||||
<label class="font-bold">{{ t('acl.rule.dst_ports') }}</label>
|
||||
<AutoComplete v-model="rule.ports" multiple fluid :suggestions="genericSuggestions"
|
||||
@complete="genericSuggestions = [$event.query]" placeholder="e.g. 80, 1000-2000" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel :header="t('advanced_settings')" toggleable collapsed>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox v-model="rule.stateful" :binary="true" inputId="rule-stateful" />
|
||||
<label for="rule-stateful" class="font-bold">{{ t('acl.rule.stateful') }}</label>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row gap-4 flex-wrap">
|
||||
<div class="flex flex-col gap-2 grow">
|
||||
<label class="font-bold">{{ t('acl.rule.rate_limit') }}</label>
|
||||
<InputNumber v-model="rule.rate_limit" :min="0" placeholder="0 = no limit" fluid />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 grow">
|
||||
<label class="font-bold">{{ t('acl.rule.burst_limit') }}</label>
|
||||
<InputNumber v-model="rule.burst_limit" :min="0" placeholder="0 = no limit" fluid />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="font-bold">{{ t('acl.rule.src_groups') }}</label>
|
||||
<MultiSelect v-model="rule.source_groups" :options="props.groupNames" multiple fluid filter
|
||||
:placeholder="t('acl.rule.src_groups')" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="font-bold">{{ t('acl.rule.dst_groups') }}</label>
|
||||
<MultiSelect v-model="rule.destination_groups" :options="props.groupNames" multiple fluid filter
|
||||
:placeholder="t('acl.rule.dst_groups')" />
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button :label="t('web.common.cancel')" icon="pi pi-times" @click="close" text />
|
||||
<Button :label="t('web.common.save')" icon="pi pi-save" @click="save" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -3,6 +3,14 @@ networking_method: 网络方式
|
||||
public_server: 公共服务器
|
||||
manual: 手动
|
||||
standalone: 独立
|
||||
initial_nodes: 初始节点
|
||||
initial_nodes_help: |
|
||||
EasyTier 不分服务端/客户端。
|
||||
• 填“初始节点” = 插上网线,直接加入已有网络。
|
||||
• 留空 = 节点独立启动,等别人来连,或你后续手动连。
|
||||
• 无论直接还是间接连通(通过其他节点搭桥),都能组网互通。
|
||||
初始节点可以用自己的,也可以用别人分享的。
|
||||
initial_node_placeholder: 例如:node.example.com
|
||||
virtual_ipv4: 虚拟IPv4地址
|
||||
virtual_ipv4_dhcp: DHCP
|
||||
network_name: 网络名称
|
||||
@@ -18,12 +26,17 @@ advanced_settings: 高级设置
|
||||
basic_settings: 基础设置
|
||||
listener_urls: 监听地址
|
||||
rpc_port: RPC端口
|
||||
port: 端口
|
||||
rpc_portal_whitelists: RPC白名单
|
||||
config_network: 配置网络
|
||||
running: 运行中
|
||||
error_msg: 错误信息
|
||||
detail: 详情
|
||||
add_new_network: 添加新网络
|
||||
add_peer_url: 添加节点
|
||||
add_initial_node: 添加初始节点
|
||||
add_listener_url: 添加监听地址
|
||||
add_mapped_listener: 添加监听映射
|
||||
del_cur_network: 删除当前网络
|
||||
select_network: 选择网络
|
||||
network_instances: 网络实例
|
||||
@@ -104,11 +117,14 @@ disable_quic_input: 禁用 QUIC 输入
|
||||
disable_quic_input_help: 禁用 QUIC 入站流量,其他开启 QUIC 代理的节点仍然使用 TCP 连接到本节点。
|
||||
|
||||
disable_p2p: 禁用 P2P
|
||||
disable_p2p_help: 禁用 P2P 模式,所有流量通过手动指定的服务器中转。
|
||||
disable_p2p_help: 禁用普通自动 P2P。开启 need-p2p 的节点仍可与当前节点建立 P2P。
|
||||
|
||||
p2p_only: 仅 P2P
|
||||
p2p_only_help: 仅与已经建立P2P连接的对等节点通信,不通过其他节点中转。
|
||||
|
||||
lazy_p2p: 延迟 P2P
|
||||
lazy_p2p_help: 仅在实际流量需要某个对等节点时才尝试建立 P2P。开启 need-p2p 的节点仍会被主动连接。
|
||||
|
||||
bind_device: 仅使用物理网卡
|
||||
bind_device_help: 仅使用物理网卡,避免 EasyTier 通过其他虚拟网建立连接。
|
||||
|
||||
@@ -123,6 +139,9 @@ relay_all_peer_rpc_help: |
|
||||
允许转发所有对等节点的RPC数据包,即使对等节点不在转发网络白名单中。
|
||||
这可以帮助白名单外网络中的对等节点建立P2P连接。
|
||||
|
||||
need_p2p: 需要 P2P
|
||||
need_p2p_help: 即使其他节点启用了 lazy p2p,也要求它们主动与当前节点建立 P2P 连接。
|
||||
|
||||
multi_thread: 启用多线程
|
||||
multi_thread_help: 使用多线程运行时
|
||||
|
||||
@@ -130,7 +149,7 @@ proxy_forward_by_system: 系统转发
|
||||
proxy_forward_by_system_help: 通过系统内核转发子网代理数据包,禁用内置NAT
|
||||
|
||||
disable_encryption: 禁用加密
|
||||
disable_encryption_help: 禁用对等节点通信的加密,默认为false,必须与对等节点相同
|
||||
disable_encryption_help: 禁用对等节点通信的加密。注意:默认启用加密,若勾选此项则关闭,必须与对等节点设置一致。
|
||||
|
||||
disable_tcp_hole_punching: 禁用TCP打洞
|
||||
disable_tcp_hole_punching_help: 禁用TCP打洞功能
|
||||
@@ -177,6 +196,12 @@ mtu_help: |
|
||||
TUN设备的MTU,默认为非加密时为1380,加密时为1360。范围:400-1380
|
||||
mtu_placeholder: 留空为默认值1380
|
||||
|
||||
instance_recv_bps_limit: 实例接收限速
|
||||
instance_recv_bps_limit_help: |
|
||||
限制当前实例整体入站流量的总接收速率,单位为字节每秒。
|
||||
留空表示不限速。
|
||||
instance_recv_bps_limit_placeholder: 留空表示不限速
|
||||
|
||||
mapped_listeners: 监听映射
|
||||
mapped_listeners_help: |
|
||||
手动指定监听器的公网地址,其他节点可以使用该地址连接到本节点。
|
||||
@@ -242,6 +267,7 @@ web:
|
||||
captcha: 验证码
|
||||
back_to_login: 返回登录
|
||||
login: 登录
|
||||
sso_login: "SSO 登录"
|
||||
|
||||
register:
|
||||
title: 注册
|
||||
@@ -329,11 +355,15 @@ web:
|
||||
delete: 删除
|
||||
edit: 编辑
|
||||
refresh: 刷新
|
||||
add: 添加
|
||||
loading: 加载中...
|
||||
error: 错误
|
||||
success: 成功
|
||||
warning: 警告
|
||||
info: 提示
|
||||
enable: 开启
|
||||
disable: 关闭
|
||||
address: 地址
|
||||
|
||||
settings:
|
||||
title: 设置
|
||||
@@ -350,6 +380,8 @@ mode:
|
||||
switch_mode: 切换模式
|
||||
config_dir: 配置目录
|
||||
rpc_portal: RPC端口
|
||||
enable_rpc_tcp_listen: 开启 RPC 端口监听(TCP)
|
||||
rpc_listen_port: RPC 监听端口
|
||||
log_level: 日志级别
|
||||
log_dir: 日志目录
|
||||
remote_rpc_address: 远程RPC地址
|
||||
@@ -370,6 +402,7 @@ mode:
|
||||
stop_service_success: 服务停止成功
|
||||
remote_rpc_address_empty: 远程RPC地址不能为空
|
||||
service_config_empty: 服务配置不能为空
|
||||
rpc_connection_failed: "RPC 连接失败:{error}"
|
||||
|
||||
config-server:
|
||||
title: 配置服务器
|
||||
@@ -390,3 +423,46 @@ config-server:
|
||||
client:
|
||||
not_running: 无法连接至远程客户端
|
||||
retry: 重试
|
||||
|
||||
acl:
|
||||
title: 访问控制
|
||||
help: 访问控制列表,用于限制节点间的通信。
|
||||
enabled: 启用 ACL
|
||||
default_action: 默认动作
|
||||
chains: 规则链
|
||||
inbound: 入站
|
||||
outbound: 出站
|
||||
forward: 转发
|
||||
rules: 规则
|
||||
add_rule: 添加规则
|
||||
edit_rule: 编辑规则
|
||||
rule:
|
||||
name: 规则名称
|
||||
description: 描述
|
||||
enabled: 启用
|
||||
protocol: 协议
|
||||
action: 动作
|
||||
src_ips: 来源 IP
|
||||
dst_ips: 目的 IP
|
||||
src_ports: 来源端口
|
||||
dst_ports: 目的端口
|
||||
rate_limit: 速率限制 (pps)
|
||||
burst_limit: 爆发限制
|
||||
stateful: 状态追踪
|
||||
src_groups: 来源组
|
||||
dst_groups: 目的组
|
||||
groups: 组管理
|
||||
group:
|
||||
declares: 声明组
|
||||
members: 加入组
|
||||
name: 组名
|
||||
secret: 密钥
|
||||
help: 在此处定义网络中的组身份,以便在规则中使用。
|
||||
any: 任意
|
||||
allow: 允许
|
||||
drop: 丢弃
|
||||
delete_chain_confirm: 确定要删除此规则链及其所有规则吗?
|
||||
chain:
|
||||
name: 名称
|
||||
type: 类型
|
||||
match: 匹配
|
||||
|
||||
@@ -3,6 +3,14 @@ networking_method: Networking Method
|
||||
public_server: Public Server
|
||||
manual: Manual
|
||||
standalone: Standalone
|
||||
initial_nodes: Initial Nodes
|
||||
initial_nodes_help: |
|
||||
EasyTier does not distinguish between server and client roles.
|
||||
• Filling in Initial Nodes = plugging in the cable and joining an existing network.
|
||||
• Leaving it empty = the node starts alone until others connect to it, or you connect it later yourself.
|
||||
• Direct or indirect connectivity, including through relay nodes, can form one network.
|
||||
Initial nodes can be your own nodes or ones shared by others.
|
||||
initial_node_placeholder: "Example: node.example.com"
|
||||
virtual_ipv4: Virtual IPv4
|
||||
virtual_ipv4_dhcp: DHCP
|
||||
network_name: Network Name
|
||||
@@ -18,12 +26,17 @@ advanced_settings: Advanced Settings
|
||||
basic_settings: Basic Settings
|
||||
listener_urls: Listener URLs
|
||||
rpc_port: RPC Port
|
||||
port: Port
|
||||
rpc_portal_whitelists: RPC Whitelist
|
||||
config_network: Config Network
|
||||
running: Running
|
||||
error_msg: Error Message
|
||||
detail: Detail
|
||||
add_new_network: New Network
|
||||
add_peer_url: Add Peer
|
||||
add_initial_node: Add Initial Node
|
||||
add_listener_url: Add Listener
|
||||
add_mapped_listener: Add Mapped Listener
|
||||
del_cur_network: Delete Current Network
|
||||
select_network: Select Network
|
||||
network_instances: Network Instances
|
||||
@@ -103,11 +116,14 @@ disable_quic_input: Disable QUIC Input
|
||||
disable_quic_input_help: Disable inbound QUIC traffic, while nodes with QUIC proxy enabled continue to connect using TCP.
|
||||
|
||||
disable_p2p: Disable P2P
|
||||
disable_p2p_help: Disable P2P mode; route all traffic through a manually specified relay server.
|
||||
disable_p2p_help: Disable ordinary automatic P2P. Nodes with need-p2p enabled can still establish P2P with this node.
|
||||
|
||||
p2p_only: P2P Only
|
||||
p2p_only_help: Only communicate with peers that have already established P2P connections, do not relay through other nodes.
|
||||
|
||||
lazy_p2p: Lazy P2P
|
||||
lazy_p2p_help: Only try to establish P2P when traffic actually targets a peer. Peers with need-p2p enabled are still connected proactively.
|
||||
|
||||
bind_device: Bind to Physical Device Only
|
||||
bind_device_help: Use only the physical network interface to prevent EasyTier from connecting via virtual networks.
|
||||
|
||||
@@ -122,6 +138,9 @@ relay_all_peer_rpc_help: |
|
||||
Relay all peer rpc packets, even if the peer is not in the relay network whitelist.
|
||||
This can help peers not in relay network whitelist to establish p2p connection.
|
||||
|
||||
need_p2p: Need P2P
|
||||
need_p2p_help: Ask other peers to proactively establish P2P connections to this node even when they enable lazy P2P.
|
||||
|
||||
multi_thread: Multi Thread
|
||||
multi_thread_help: Use multi-thread runtime
|
||||
|
||||
@@ -129,7 +148,7 @@ proxy_forward_by_system: System Forward
|
||||
proxy_forward_by_system_help: Forward packet to proxy networks via system kernel, disable internal nat for network proxy
|
||||
|
||||
disable_encryption: Disable Encryption
|
||||
disable_encryption_help: Disable encryption for peers communication, default is false, must be same with peers
|
||||
disable_encryption_help: Disable encryption for peers communication. Encryption is enabled by default, this option must be same with peers.
|
||||
|
||||
disable_tcp_hole_punching: Disable TCP Hole Punching
|
||||
disable_tcp_hole_punching_help: Disable tcp hole punching
|
||||
@@ -177,6 +196,12 @@ mtu_help: |
|
||||
MTU of the TUN device, default is 1380 for non-encryption, 1360 for encryption. Range:400-1380
|
||||
mtu_placeholder: Leave blank as default value 1380
|
||||
|
||||
instance_recv_bps_limit: Instance Receive Limit
|
||||
instance_recv_bps_limit_help: |
|
||||
Limit the total receive bandwidth for the whole instance. Unit: bytes per second.
|
||||
Leave blank for no limit.
|
||||
instance_recv_bps_limit_placeholder: Leave blank for no limit
|
||||
|
||||
mapped_listeners: Map Listeners
|
||||
mapped_listeners_help: |
|
||||
Manually specify the public address of the listener, other nodes can use this address to connect to this node.
|
||||
@@ -242,6 +267,7 @@ web:
|
||||
captcha: Captcha
|
||||
back_to_login: Back to Login
|
||||
login: Login
|
||||
sso_login: "SSO Login"
|
||||
|
||||
register:
|
||||
title: Register
|
||||
@@ -329,11 +355,15 @@ web:
|
||||
delete: Delete
|
||||
edit: Edit
|
||||
refresh: Refresh
|
||||
add: Add
|
||||
loading: Loading...
|
||||
error: Error
|
||||
success: Success
|
||||
warning: Warning
|
||||
info: Info
|
||||
enable: Enable
|
||||
disable: Disable
|
||||
address: Address
|
||||
|
||||
settings:
|
||||
title: Settings
|
||||
@@ -350,6 +380,8 @@ mode:
|
||||
switch_mode: Switch Mode
|
||||
config_dir: Config Dir
|
||||
rpc_portal: RPC Portal
|
||||
enable_rpc_tcp_listen: Enable RPC port listening (TCP)
|
||||
rpc_listen_port: RPC Listen Port
|
||||
log_level: Log Level
|
||||
log_dir: Log Dir
|
||||
remote_rpc_address: Remote RPC Address
|
||||
@@ -370,6 +402,7 @@ mode:
|
||||
stop_service_success: Service stopped successfully
|
||||
remote_rpc_address_empty: Remote RPC Address cannot be empty
|
||||
service_config_empty: Service Config cannot be empty
|
||||
rpc_connection_failed: "RPC connection failed: {error}"
|
||||
|
||||
config-server:
|
||||
title: Config Server
|
||||
@@ -390,3 +423,46 @@ config-server:
|
||||
client:
|
||||
not_running: Unable to connect to remote client.
|
||||
retry: Retry
|
||||
|
||||
acl:
|
||||
title: Access Control (ACL)
|
||||
help: Access control list to restrict communication between nodes.
|
||||
enabled: Enable ACL
|
||||
default_action: Default Action
|
||||
chains: Rule Chains
|
||||
inbound: Inbound
|
||||
outbound: Outbound
|
||||
forward: Forward
|
||||
rules: Rules
|
||||
add_rule: Add Rule
|
||||
edit_rule: Edit Rule
|
||||
rule:
|
||||
name: Rule Name
|
||||
description: Description
|
||||
enabled: Enabled
|
||||
protocol: Protocol
|
||||
action: Action
|
||||
src_ips: Source IPs
|
||||
dst_ips: Destination IPs
|
||||
src_ports: Source Ports
|
||||
dst_ports: Destination Ports
|
||||
rate_limit: Rate Limit (pps)
|
||||
burst_limit: Burst Limit
|
||||
stateful: Stateful
|
||||
src_groups: Source Groups
|
||||
dst_groups: Destination Groups
|
||||
groups: Groups
|
||||
group:
|
||||
declares: Declared Groups
|
||||
members: Node Memberships
|
||||
name: Group Name
|
||||
secret: Group Secret
|
||||
help: Define group identities in the network to use them in rules.
|
||||
any: Any
|
||||
allow: Allow
|
||||
drop: Drop
|
||||
delete_chain_confirm: Are you sure you want to delete this rule chain and all its rules?
|
||||
chain:
|
||||
name: Name
|
||||
type: Type
|
||||
match: Match
|
||||
|
||||
@@ -49,4 +49,6 @@
|
||||
|
||||
.v-popper__inner {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
max-width: 32rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,82 @@ export enum NetworkingMethod {
|
||||
Standalone = 2,
|
||||
}
|
||||
|
||||
export interface SecureModeConfig {
|
||||
enabled: boolean
|
||||
// Keep protocol compatibility with backend/import-export flows even though the GUI
|
||||
// does not render secure-mode or credential inputs.
|
||||
local_private_key?: string
|
||||
local_public_key?: string
|
||||
}
|
||||
|
||||
export enum AclProtocol {
|
||||
Unspecified = 0,
|
||||
TCP = 1,
|
||||
UDP = 2,
|
||||
ICMP = 3,
|
||||
ICMPv6 = 4,
|
||||
Any = 5,
|
||||
}
|
||||
|
||||
export enum AclAction {
|
||||
Noop = 0,
|
||||
Allow = 1,
|
||||
Drop = 2,
|
||||
}
|
||||
|
||||
export enum AclChainType {
|
||||
UnspecifiedChain = 0,
|
||||
Inbound = 1,
|
||||
Outbound = 2,
|
||||
Forward = 3,
|
||||
}
|
||||
|
||||
export interface AclRule {
|
||||
name: string
|
||||
description: string
|
||||
priority: number
|
||||
enabled: boolean
|
||||
protocol: AclProtocol
|
||||
ports: string[]
|
||||
source_ips: string[]
|
||||
destination_ips: string[]
|
||||
source_ports: string[]
|
||||
action: AclAction
|
||||
rate_limit: number
|
||||
burst_limit: number
|
||||
stateful: boolean
|
||||
source_groups: string[]
|
||||
destination_groups: string[]
|
||||
}
|
||||
|
||||
export interface AclChain {
|
||||
name: string
|
||||
chain_type: AclChainType
|
||||
description: string
|
||||
enabled: boolean
|
||||
rules: AclRule[]
|
||||
default_action: AclAction
|
||||
}
|
||||
|
||||
export interface GroupIdentity {
|
||||
group_name: string
|
||||
group_secret: string
|
||||
}
|
||||
|
||||
export interface GroupInfo {
|
||||
declares: GroupIdentity[]
|
||||
members: string[]
|
||||
}
|
||||
|
||||
export interface AclV1 {
|
||||
chains: AclChain[]
|
||||
group?: GroupInfo
|
||||
}
|
||||
|
||||
export interface Acl {
|
||||
acl_v1?: AclV1
|
||||
}
|
||||
|
||||
export interface NetworkConfig {
|
||||
instance_id: string
|
||||
|
||||
@@ -14,7 +90,9 @@ export interface NetworkConfig {
|
||||
network_length: number
|
||||
hostname?: string
|
||||
network_name: string
|
||||
network_secret: string
|
||||
network_secret?: string
|
||||
credential_file?: string
|
||||
secure_mode?: SecureModeConfig
|
||||
|
||||
networking_method: NetworkingMethod
|
||||
|
||||
@@ -43,10 +121,12 @@ export interface NetworkConfig {
|
||||
disable_quic_input?: boolean
|
||||
disable_p2p?: boolean
|
||||
p2p_only?: boolean
|
||||
lazy_p2p?: boolean
|
||||
bind_device?: boolean
|
||||
no_tun?: boolean
|
||||
enable_exit_node?: boolean
|
||||
relay_all_peer_rpc?: boolean
|
||||
need_p2p?: boolean
|
||||
multi_thread?: boolean
|
||||
proxy_forward_by_system?: boolean
|
||||
disable_encryption?: boolean
|
||||
@@ -66,12 +146,14 @@ export interface NetworkConfig {
|
||||
socks5_port: number
|
||||
|
||||
mtu: number | null
|
||||
instance_recv_bps_limit: number | null
|
||||
mapped_listeners: string[]
|
||||
|
||||
enable_magic_dns?: boolean
|
||||
enable_private_mode?: boolean
|
||||
|
||||
port_forwards: PortForwardConfig[]
|
||||
acl?: Acl
|
||||
}
|
||||
|
||||
export function DEFAULT_NETWORK_CONFIG(): NetworkConfig {
|
||||
@@ -83,10 +165,10 @@ export function DEFAULT_NETWORK_CONFIG(): NetworkConfig {
|
||||
network_length: 24,
|
||||
network_name: 'easytier',
|
||||
network_secret: '',
|
||||
credential_file: '',
|
||||
|
||||
networking_method: NetworkingMethod.PublicServer,
|
||||
|
||||
public_server_url: 'tcp://public.easytier.top:11010',
|
||||
networking_method: NetworkingMethod.Manual,
|
||||
public_server_url: '',
|
||||
peer_urls: [],
|
||||
|
||||
proxy_cidrs: [],
|
||||
@@ -114,10 +196,12 @@ export function DEFAULT_NETWORK_CONFIG(): NetworkConfig {
|
||||
disable_quic_input: false,
|
||||
disable_p2p: false,
|
||||
p2p_only: false,
|
||||
lazy_p2p: false,
|
||||
bind_device: true,
|
||||
no_tun: false,
|
||||
enable_exit_node: false,
|
||||
relay_all_peer_rpc: false,
|
||||
need_p2p: false,
|
||||
multi_thread: true,
|
||||
proxy_forward_by_system: false,
|
||||
disable_encryption: false,
|
||||
@@ -132,13 +216,56 @@ export function DEFAULT_NETWORK_CONFIG(): NetworkConfig {
|
||||
enable_socks5: false,
|
||||
socks5_port: 1080,
|
||||
mtu: null,
|
||||
instance_recv_bps_limit: null,
|
||||
mapped_listeners: [],
|
||||
enable_magic_dns: false,
|
||||
enable_private_mode: false,
|
||||
port_forwards: [],
|
||||
acl: {
|
||||
acl_v1: {
|
||||
group: {
|
||||
declares: [],
|
||||
members: [],
|
||||
},
|
||||
chains: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function cleanPeerUrls(urls: string[] | undefined): string[] {
|
||||
return (urls ?? []).map((url) => url.trim()).filter((url) => url.length > 0)
|
||||
}
|
||||
|
||||
export function normalizeNetworkConfig(config: NetworkConfig): NetworkConfig {
|
||||
const normalized: NetworkConfig = {
|
||||
...config,
|
||||
peer_urls: cleanPeerUrls(config.peer_urls),
|
||||
}
|
||||
|
||||
const publicServerUrl = normalized.public_server_url?.trim() ?? ''
|
||||
|
||||
switch (normalized.networking_method) {
|
||||
case NetworkingMethod.PublicServer:
|
||||
normalized.peer_urls = publicServerUrl ? [publicServerUrl] : []
|
||||
break
|
||||
case NetworkingMethod.Manual:
|
||||
break
|
||||
case NetworkingMethod.Standalone:
|
||||
default:
|
||||
normalized.peer_urls = []
|
||||
break
|
||||
}
|
||||
|
||||
normalized.networking_method = NetworkingMethod.Manual
|
||||
normalized.public_server_url = ''
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function toBackendNetworkConfig(config: NetworkConfig): NetworkConfig {
|
||||
return normalizeNetworkConfig(config)
|
||||
}
|
||||
|
||||
export interface NetworkInstance {
|
||||
instance_id: string
|
||||
|
||||
@@ -204,6 +331,7 @@ export interface NodeInfo {
|
||||
stun_info: StunInfo
|
||||
listeners: Url[]
|
||||
vpn_portal_cfg?: string
|
||||
peer_id: number
|
||||
}
|
||||
|
||||
export interface StunInfo {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"dependencies": {
|
||||
"@modyfi/vite-plugin-yaml": "^1.1.0",
|
||||
"@primeuix/themes": "^1.2.3",
|
||||
"axios": "^1.7.7",
|
||||
"axios": "^1.13.5",
|
||||
"easytier-frontend-lib": "workspace:*",
|
||||
"primevue": "^4.3.9",
|
||||
"tailwindcss-primeui": "^0.3.4",
|
||||
@@ -28,7 +28,7 @@
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "=3.4.17",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^5.4.10",
|
||||
"vite": "^5.4.21",
|
||||
"vite-plugin-singlefile": "^2.0.3",
|
||||
"vue-tsc": "^2.1.10"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { Card, InputText, Password, Button, AutoComplete } from 'primevue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
@@ -68,8 +68,43 @@ const apiHostSearch = async (event: { query: string }) => {
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const oidcEnabled = ref(false);
|
||||
const lastCheckedHost = ref('');
|
||||
const oidcCheckTimer = ref<ReturnType<typeof setTimeout> | null>(null);
|
||||
const checkOidcConfig = () => {
|
||||
if (oidcCheckTimer.value) clearTimeout(oidcCheckTimer.value);
|
||||
oidcCheckTimer.value = setTimeout(async () => {
|
||||
const host = apiHost.value;
|
||||
if (host === lastCheckedHost.value) return;
|
||||
|
||||
const enabled = (await new ApiClient(host).getOidcConfig()).enabled;
|
||||
// If host changes while request is in-flight, do not overwrite UI state.
|
||||
if (apiHost.value !== host) return;
|
||||
|
||||
lastCheckedHost.value = host;
|
||||
oidcEnabled.value = enabled;
|
||||
}, 300);
|
||||
};
|
||||
|
||||
watch(apiHost, () => {
|
||||
checkOidcConfig();
|
||||
});
|
||||
|
||||
const onSsoLogin = () => {
|
||||
saveApiHost(apiHost.value);
|
||||
localStorage.setItem('apiHost', btoa(apiHost.value));
|
||||
window.location.href = api.value.oidcLoginUrl();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
checkOidcConfig();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (oidcCheckTimer.value) {
|
||||
clearTimeout(oidcCheckTimer.value);
|
||||
oidcCheckTimer.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
@@ -104,6 +139,10 @@ onMounted(() => {
|
||||
<Button :label="t('web.login.register')" type="button" class="w-full"
|
||||
@click="saveApiHost(apiHost); $router.replace({ name: 'register' })" severity="secondary" />
|
||||
</div>
|
||||
<div v-if="oidcEnabled" class="flex items-center justify-between">
|
||||
<Button :label="t('web.login.sso_login')" type="button" class="w-full" severity="info"
|
||||
@click="onSsoLogin" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form v-else @submit.prevent="onRegister" class="space-y-4">
|
||||
@@ -144,4 +183,4 @@ onMounted(() => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
<style scoped></style>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import axios, { AxiosError, AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
|
||||
import { type Api, type NetworkTypes, Utils } from 'easytier-frontend-lib';
|
||||
import { type Api, NetworkTypes, Utils } from 'easytier-frontend-lib';
|
||||
import { Md5 } from 'ts-md5';
|
||||
|
||||
export interface ValidateConfigResponse {
|
||||
toml_config: string;
|
||||
}
|
||||
|
||||
export interface OidcConfigResponse {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// 定义接口返回的数据结构
|
||||
export interface LoginResponse {
|
||||
success: boolean;
|
||||
@@ -174,6 +178,19 @@ export class ApiClient {
|
||||
return this.client.defaults.baseURL + '/auth/captcha';
|
||||
}
|
||||
|
||||
public async getOidcConfig(): Promise<OidcConfigResponse> {
|
||||
try {
|
||||
const response = await this.client.get<any, OidcConfigResponse>('/auth/oidc/config');
|
||||
return response;
|
||||
} catch (error) {
|
||||
return { enabled: false };
|
||||
}
|
||||
}
|
||||
|
||||
public oidcLoginUrl() {
|
||||
return this.client.defaults.baseURL + '/auth/oidc/login';
|
||||
}
|
||||
|
||||
public get_remote_client(machine_id: string): Api.RemoteClient {
|
||||
return new WebRemoteClient(machine_id, this.client);
|
||||
}
|
||||
@@ -189,13 +206,13 @@ class WebRemoteClient implements Api.RemoteClient {
|
||||
}
|
||||
async validate_config(config: NetworkTypes.NetworkConfig): Promise<Api.ValidateConfigResponse> {
|
||||
const response = await this.client.post<NetworkTypes.NetworkConfig, ValidateConfigResponse>(`/machines/${this.machine_id}/validate-config`, {
|
||||
config: config,
|
||||
config: NetworkTypes.toBackendNetworkConfig(config),
|
||||
});
|
||||
return response;
|
||||
}
|
||||
async run_network(config: NetworkTypes.NetworkConfig, save: boolean): Promise<undefined> {
|
||||
await this.client.post<string>(`/machines/${this.machine_id}/networks`, {
|
||||
config: config,
|
||||
config: NetworkTypes.toBackendNetworkConfig(config),
|
||||
save: save
|
||||
});
|
||||
}
|
||||
@@ -216,15 +233,19 @@ class WebRemoteClient implements Api.RemoteClient {
|
||||
});
|
||||
}
|
||||
async save_config(config: NetworkTypes.NetworkConfig): Promise<undefined> {
|
||||
await this.client.put(`/machines/${this.machine_id}/networks/config/${config.instance_id}`, { config });
|
||||
await this.client.put(`/machines/${this.machine_id}/networks/config/${config.instance_id}`, {
|
||||
config: NetworkTypes.toBackendNetworkConfig(config)
|
||||
});
|
||||
}
|
||||
async get_network_config(inst_id: string): Promise<NetworkTypes.NetworkConfig> {
|
||||
const response = await this.client.get<any, NetworkTypes.NetworkConfig>('/machines/' + this.machine_id + '/networks/config/' + inst_id);
|
||||
return response;
|
||||
return NetworkTypes.normalizeNetworkConfig(response);
|
||||
}
|
||||
async generate_config(config: NetworkTypes.NetworkConfig): Promise<Api.GenerateConfigResponse> {
|
||||
try {
|
||||
const response = await this.client.post<any, GenerateConfigResponse>('/generate-config', { config });
|
||||
const response = await this.client.post<any, GenerateConfigResponse>('/generate-config', {
|
||||
config: NetworkTypes.toBackendNetworkConfig(config)
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
@@ -236,6 +257,9 @@ class WebRemoteClient implements Api.RemoteClient {
|
||||
async parse_config(toml_config: string): Promise<Api.ParseConfigResponse> {
|
||||
try {
|
||||
const response = await this.client.post<any, ParseConfigResponse>('/parse-config', { toml_config });
|
||||
if (response.config) {
|
||||
response.config = NetworkTypes.normalizeNetworkConfig(response.config);
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
@@ -252,4 +276,4 @@ class WebRemoteClient implements Api.RemoteClient {
|
||||
}
|
||||
}
|
||||
|
||||
export default ApiClient;
|
||||
export default ApiClient;
|
||||
|
||||
@@ -17,14 +17,20 @@ cli:
|
||||
en: "The port to listen for the config server, used by the easytier-core to connect to"
|
||||
zh-CN: "配置服务器的监听端口,用于被 easytier-core 连接"
|
||||
config_server_protocol:
|
||||
en: "The protocol to listen for the config server, used by the easytier-core to connect to"
|
||||
zh-CN: "配置服务器的监听协议,用于被 easytier-core 连接, 可能的值:udp, tcp"
|
||||
en: "The protocol to listen for the config server, used by the easytier-core to connect to, possible values: udp, tcp, ws"
|
||||
zh-CN: "配置服务器的监听协议,用于被 easytier-core 连接, 可能的值:udp, tcp, ws"
|
||||
api_server_port:
|
||||
en: "The port to listen for the restful server, acting as ApiHost and used by the web frontend"
|
||||
zh-CN: "restful 服务器的监听端口,作为 ApiHost 并被 web 前端使用"
|
||||
api_server_addr:
|
||||
en: "The listen address for the restful server, e.g. 0.0.0.0, ::, 127.0.0.1"
|
||||
zh-CN: "restful 服务器的监听地址, 例如 0.0.0.0, ::, 127.0.0.1"
|
||||
web_server_port:
|
||||
en: "The port to listen for the web dashboard server, default is same as the api server port"
|
||||
zh-CN: "web dashboard 服务器的监听端口, 默认为与 api 服务器端口相同"
|
||||
web_server_addr:
|
||||
en: "The listen address for the web dashboard server (only effective when web_server_port differs from api_server_port or web_server_addr differs from api_server_addr), e.g. 0.0.0.0, ::, 127.0.0.1"
|
||||
zh-CN: "web dashboard 服务器的监听地址(仅在 web_server_port 与 api_server_port 不同,或 web_server_addr 与 api_server_addr 不同时生效), 例如 0.0.0.0, ::, 127.0.0.1"
|
||||
no_web:
|
||||
en: "Do not run the web dashboard server"
|
||||
zh-CN: "不运行 web dashboard 服务器"
|
||||
@@ -33,4 +39,34 @@ cli:
|
||||
zh-CN: "API 服务器的 URL,用于 web 前端连接"
|
||||
geoip_db:
|
||||
en: "The path to the GeoIP2 database file, used to lookup the location of the client, default is the embedded file (only country information) , recommend https://github.com/P3TERX/GeoLite.mmdb"
|
||||
zh-CN: "GeoIP2 数据库文件路径,用于查找客户端的位置,默认为嵌入文件(仅国家信息),推荐 https://github.com/P3TERX/GeoLite.mmdb"
|
||||
zh-CN: "GeoIP2 数据库文件路径,用于查找客户端的位置,默认为嵌入文件(仅国家信息),推荐 https://github.com/P3TERX/GeoLite.mmdb"
|
||||
disable_registration:
|
||||
en: "Disable user registration"
|
||||
zh-CN: "禁用用户注册"
|
||||
oidc_issuer_url:
|
||||
en: "The OIDC issuer URL for single sign-on authentication"
|
||||
zh-CN: "OIDC 签发者 URL,用于单点登录认证"
|
||||
oidc_client_id:
|
||||
en: "The OIDC client ID"
|
||||
zh-CN: "OIDC 客户端 ID"
|
||||
oidc_client_secret:
|
||||
en: "The OIDC client secret (can also be set via OIDC_CLIENT_SECRET env var)"
|
||||
zh-CN: "OIDC 客户端密钥(也可通过 OIDC_CLIENT_SECRET 环境变量设置)"
|
||||
oidc_username_claim:
|
||||
en: "The OIDC claim to use as the local username, default: preferred_username"
|
||||
zh-CN: "用作本地用户名的 OIDC claim 字段,默认: preferred_username"
|
||||
oidc_scopes:
|
||||
en: "OIDC scopes to request during login. Supports comma-separated values or repeated --oidc-scopes flags, default: openid,profile"
|
||||
zh-CN: "登录时请求的 OIDC scopes。支持逗号分隔或多次指定 --oidc-scopes,默认: openid,profile"
|
||||
oidc_redirect_url:
|
||||
en: "The OIDC redirect URL (callback URL), must match exactly what is registered with your Identity Provider. Required when using OIDC. Example: http://your-domain.com:11211/api/v1/auth/oidc/callback"
|
||||
zh-CN: "OIDC 重定向 URL(回调 URL),必须与身份提供商注册的地址完全一致。使用 OIDC 时必须提供。示例: http://your-domain.com:11211/api/v1/auth/oidc/callback"
|
||||
allow_auto_create_user:
|
||||
en: "Allow auto-creating local user when easytier-core connects with an unknown username"
|
||||
zh-CN: "当 easytier-core 使用未知用户名连接时,允许自动创建本地用户"
|
||||
oidc_disable_pkce:
|
||||
en: "Disable PKCE (Proof Key for Code Exchange) for OIDC authentication"
|
||||
zh-CN: "禁用 OIDC 认证的 PKCE(授权码交换证明密钥)"
|
||||
oidc_frontend_base_url:
|
||||
en: "Frontend base URL to redirect to after successful OIDC callback. Required when frontend and API are deployed separately (non-embed build, --no-web mode, or different web_server_port)"
|
||||
zh-CN: "OIDC 回调成功后跳转的前端入口地址。当前端与 API 分离部署时必须提供(非 embed 构建、--no-web 模式、或 web_server_port 与 api_server_port 不同)"
|
||||
|
||||
@@ -2,8 +2,8 @@ pub mod session;
|
||||
pub mod storage;
|
||||
|
||||
use std::sync::{
|
||||
atomic::{AtomicU32, Ordering},
|
||||
Arc,
|
||||
atomic::{AtomicU32, Ordering},
|
||||
};
|
||||
|
||||
use dashmap::DashMap;
|
||||
@@ -13,13 +13,17 @@ use easytier::{
|
||||
},
|
||||
rpc_service::remote_client::{self, RemoteClientManager},
|
||||
tunnel::TunnelListener,
|
||||
web_client::security,
|
||||
};
|
||||
use maxminddb::geoip2;
|
||||
use session::{Location, Session};
|
||||
use storage::{Storage, StorageToken};
|
||||
|
||||
use crate::FeatureFlags;
|
||||
use crate::webhook::SharedWebhookConfig;
|
||||
use tokio::task::JoinSet;
|
||||
|
||||
use crate::db::{entity::user_running_network_configs, Db, UserIdInDb};
|
||||
use crate::db::{Db, UserIdInDb, entity::user_running_network_configs};
|
||||
|
||||
#[derive(rust_embed::Embed)]
|
||||
#[folder = "resources/"]
|
||||
@@ -55,11 +59,19 @@ pub struct ClientManager {
|
||||
client_sessions: Arc<DashMap<url::Url, Arc<Session>>>,
|
||||
storage: Storage,
|
||||
|
||||
feature_flags: Arc<FeatureFlags>,
|
||||
webhook_config: SharedWebhookConfig,
|
||||
|
||||
geoip_db: Arc<Option<maxminddb::Reader<Vec<u8>>>>,
|
||||
}
|
||||
|
||||
impl ClientManager {
|
||||
pub fn new(db: Db, geoip_db: Option<String>) -> Self {
|
||||
pub fn new(
|
||||
db: Db,
|
||||
geoip_db: Option<String>,
|
||||
feature_flags: Arc<FeatureFlags>,
|
||||
webhook_config: SharedWebhookConfig,
|
||||
) -> Self {
|
||||
let client_sessions = Arc::new(DashMap::new());
|
||||
let sessions: Arc<DashMap<url::Url, Arc<Session>>> = client_sessions.clone();
|
||||
let mut tasks = JoinSet::new();
|
||||
@@ -76,6 +88,9 @@ impl ClientManager {
|
||||
|
||||
client_sessions,
|
||||
storage: Storage::new(db),
|
||||
feature_flags,
|
||||
webhook_config,
|
||||
|
||||
geoip_db: Arc::new(load_geoip_db(geoip_db)),
|
||||
}
|
||||
}
|
||||
@@ -90,17 +105,33 @@ impl ClientManager {
|
||||
let storage = self.storage.weak_ref();
|
||||
let listeners_cnt = self.listeners_cnt.clone();
|
||||
let geoip_db = self.geoip_db.clone();
|
||||
let feature_flags = self.feature_flags.clone();
|
||||
let webhook_config = self.webhook_config.clone();
|
||||
self.tasks.spawn(async move {
|
||||
while let Ok(tunnel) = listener.accept().await {
|
||||
let (tunnel, secure) = match security::accept_or_upgrade_server_tunnel(tunnel).await {
|
||||
Ok(v) => v,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "failed to accept secure tunnel, dropping connection");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let info = tunnel.info().unwrap();
|
||||
let client_url: url::Url = info.remote_addr.unwrap().into();
|
||||
let location = Self::lookup_location(&client_url, geoip_db.clone());
|
||||
tracing::info!(
|
||||
"New session from {:?}, location: {:?}",
|
||||
"New session from {:?}, secure: {}, location: {:?}",
|
||||
client_url,
|
||||
secure,
|
||||
location
|
||||
);
|
||||
let mut session = Session::new(storage.clone(), client_url.clone(), location);
|
||||
let mut session = Session::new(
|
||||
storage.clone(),
|
||||
client_url.clone(),
|
||||
location,
|
||||
feature_flags.clone(),
|
||||
webhook_config.clone(),
|
||||
);
|
||||
session.serve(tunnel).await;
|
||||
sessions.insert(client_url, Arc::new(session));
|
||||
}
|
||||
@@ -144,6 +175,24 @@ impl ClientManager {
|
||||
.map(|item| item.value().clone())
|
||||
}
|
||||
|
||||
pub async fn disconnect_session_by_machine_id(
|
||||
&self,
|
||||
user_id: UserIdInDb,
|
||||
machine_id: &uuid::Uuid,
|
||||
) -> bool {
|
||||
let Some(client_url) = self
|
||||
.storage
|
||||
.get_client_url_by_machine_id(user_id, machine_id)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some((_, session)) = self.client_sessions.remove(&client_url) else {
|
||||
return false;
|
||||
};
|
||||
session.stop().await;
|
||||
true
|
||||
}
|
||||
|
||||
pub async fn list_machine_by_user_id(&self, user_id: UserIdInDb) -> Vec<url::Url> {
|
||||
self.storage.list_user_clients(user_id)
|
||||
}
|
||||
@@ -291,12 +340,19 @@ mod tests {
|
||||
};
|
||||
use sqlx::Executor;
|
||||
|
||||
use crate::{client_manager::ClientManager, db::Db};
|
||||
use crate::{FeatureFlags, client_manager::ClientManager, db::Db};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_client() {
|
||||
let listener = UdpTunnelListener::new("udp://0.0.0.0:54333".parse().unwrap());
|
||||
let mut mgr = ClientManager::new(Db::memory_db().await, None);
|
||||
let mut mgr = ClientManager::new(
|
||||
Db::memory_db().await,
|
||||
None,
|
||||
Arc::new(FeatureFlags::default()),
|
||||
Arc::new(crate::webhook::WebhookConfig::new(
|
||||
None, None, None, None, None,
|
||||
)),
|
||||
);
|
||||
mgr.add_listener(Box::new(listener)).await.unwrap();
|
||||
|
||||
mgr.db()
|
||||
@@ -310,26 +366,43 @@ mod tests {
|
||||
connector,
|
||||
"test",
|
||||
"test",
|
||||
false,
|
||||
Arc::new(NetworkInstanceManager::new()),
|
||||
None,
|
||||
);
|
||||
|
||||
wait_for_condition(
|
||||
|| async { mgr.client_sessions.len() == 1 },
|
||||
Duration::from_secs(6),
|
||||
|| async { !mgr.client_sessions.is_empty() },
|
||||
Duration::from_secs(12),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut a = mgr
|
||||
.client_sessions
|
||||
.iter()
|
||||
.next()
|
||||
.unwrap()
|
||||
.data()
|
||||
.read()
|
||||
.await
|
||||
.heartbeat_waiter();
|
||||
let req = a.recv().await.unwrap();
|
||||
let req = tokio::time::timeout(Duration::from_secs(12), async {
|
||||
loop {
|
||||
let sessions = mgr
|
||||
.client_sessions
|
||||
.iter()
|
||||
.map(|item| item.value().clone())
|
||||
.collect::<Vec<_>>();
|
||||
if sessions.is_empty() {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
continue;
|
||||
}
|
||||
let mut found_req = None;
|
||||
for session in sessions {
|
||||
if let Some(req) = session.data().read().await.req() {
|
||||
found_req = Some(req);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(req) = found_req {
|
||||
break req;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
println!("{:?}", req);
|
||||
println!("{:?}", mgr);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
use std::{fmt::Debug, str::FromStr as _, sync::Arc};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fmt::Debug,
|
||||
str::FromStr as _,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use anyhow::Context;
|
||||
use easytier::{
|
||||
@@ -15,9 +20,11 @@ use easytier::{
|
||||
rpc_service::remote_client::{ListNetworkProps, Storage as _},
|
||||
tunnel::Tunnel,
|
||||
};
|
||||
use tokio::sync::{broadcast, RwLock};
|
||||
use tokio::sync::{RwLock, broadcast};
|
||||
|
||||
use super::storage::{Storage, StorageToken, WeakRefStorage};
|
||||
use crate::FeatureFlags;
|
||||
use crate::webhook::SharedWebhookConfig;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Location {
|
||||
@@ -29,22 +36,36 @@ pub struct Location {
|
||||
#[derive(Debug)]
|
||||
pub struct SessionData {
|
||||
storage: WeakRefStorage,
|
||||
feature_flags: Arc<FeatureFlags>,
|
||||
webhook_config: SharedWebhookConfig,
|
||||
client_url: url::Url,
|
||||
|
||||
storage_token: Option<StorageToken>,
|
||||
binding_version: Option<u64>,
|
||||
applied_config_revision: Option<String>,
|
||||
notifier: broadcast::Sender<HeartbeatRequest>,
|
||||
req: Option<HeartbeatRequest>,
|
||||
location: Option<Location>,
|
||||
}
|
||||
|
||||
impl SessionData {
|
||||
fn new(storage: WeakRefStorage, client_url: url::Url, location: Option<Location>) -> Self {
|
||||
fn new(
|
||||
storage: WeakRefStorage,
|
||||
client_url: url::Url,
|
||||
location: Option<Location>,
|
||||
feature_flags: Arc<FeatureFlags>,
|
||||
webhook_config: SharedWebhookConfig,
|
||||
) -> Self {
|
||||
let (tx, _rx1) = broadcast::channel(2);
|
||||
|
||||
SessionData {
|
||||
storage,
|
||||
feature_flags,
|
||||
webhook_config,
|
||||
client_url,
|
||||
storage_token: None,
|
||||
binding_version: None,
|
||||
applied_config_revision: None,
|
||||
notifier: tx,
|
||||
req: None,
|
||||
location,
|
||||
@@ -66,9 +87,30 @@ impl SessionData {
|
||||
|
||||
impl Drop for SessionData {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(storage) = Storage::try_from(self.storage.clone()) {
|
||||
if let Some(token) = self.storage_token.as_ref() {
|
||||
storage.remove_client(token);
|
||||
if let Ok(storage) = Storage::try_from(self.storage.clone())
|
||||
&& let Some(token) = self.storage_token.as_ref()
|
||||
{
|
||||
storage.remove_client(token);
|
||||
|
||||
// Notify the webhook receiver when a node disconnects.
|
||||
if self.webhook_config.is_enabled() {
|
||||
let webhook = self.webhook_config.clone();
|
||||
let machine_id = token.machine_id.to_string();
|
||||
let user_id = Some(token.user_id);
|
||||
let token_value = token.token.clone();
|
||||
let web_instance_id = webhook.web_instance_id.clone();
|
||||
let binding_version = self.binding_version;
|
||||
tokio::spawn(async move {
|
||||
webhook
|
||||
.notify_node_disconnected(&crate::webhook::NodeDisconnectedRequest {
|
||||
machine_id,
|
||||
token: token_value,
|
||||
user_id,
|
||||
web_instance_id,
|
||||
binding_version,
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,6 +124,89 @@ struct SessionRpcService {
|
||||
}
|
||||
|
||||
impl SessionRpcService {
|
||||
fn normalize_network_config(
|
||||
mut network_config: serde_json::Value,
|
||||
inst_id: uuid::Uuid,
|
||||
) -> anyhow::Result<NetworkConfig> {
|
||||
let network_name = network_config
|
||||
.get("network_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|v| !v.is_empty())
|
||||
.ok_or_else(|| anyhow::anyhow!("webhook response missing network_name"))?
|
||||
.to_string();
|
||||
let config_obj = network_config
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| anyhow::anyhow!("webhook network_config must be a JSON object"))?;
|
||||
config_obj.insert(
|
||||
"instance_id".to_string(),
|
||||
serde_json::Value::String(inst_id.to_string()),
|
||||
);
|
||||
config_obj
|
||||
.entry("instance_name".to_string())
|
||||
.or_insert_with(|| serde_json::Value::String(network_name));
|
||||
|
||||
Ok(serde_json::from_value::<NetworkConfig>(network_config)?)
|
||||
}
|
||||
|
||||
async fn reconcile_managed_network_configs(
|
||||
storage: &Storage,
|
||||
user_id: i32,
|
||||
machine_id: uuid::Uuid,
|
||||
desired_configs: Vec<crate::webhook::ManagedNetworkConfig>,
|
||||
) -> anyhow::Result<()> {
|
||||
let existing_configs = storage
|
||||
.db()
|
||||
.list_network_configs((user_id, machine_id), ListNetworkProps::All)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("failed to list existing network configs: {:?}", e))?;
|
||||
let existing_ids = existing_configs
|
||||
.iter()
|
||||
.filter_map(|cfg| uuid::Uuid::parse_str(&cfg.network_instance_id).ok())
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
let mut desired_ids = HashSet::with_capacity(desired_configs.len());
|
||||
let mut normalized = HashMap::with_capacity(desired_configs.len());
|
||||
for desired in desired_configs {
|
||||
let inst_id = uuid::Uuid::parse_str(&desired.instance_id).with_context(|| {
|
||||
format!(
|
||||
"invalid desired managed instance id: {}",
|
||||
desired.instance_id
|
||||
)
|
||||
})?;
|
||||
let config = Self::normalize_network_config(desired.network_config, inst_id)?;
|
||||
desired_ids.insert(inst_id);
|
||||
normalized.insert(inst_id, config);
|
||||
}
|
||||
|
||||
for (inst_id, config) in normalized {
|
||||
storage
|
||||
.db()
|
||||
.insert_or_update_user_network_config((user_id, machine_id), inst_id, config)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"failed to persist managed network config {}: {:?}",
|
||||
inst_id,
|
||||
e
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
let stale_ids = existing_ids
|
||||
.difference(&desired_ids)
|
||||
.copied()
|
||||
.collect::<Vec<_>>();
|
||||
if !stale_ids.is_empty() {
|
||||
storage
|
||||
.db()
|
||||
.delete_network_configs((user_id, machine_id), &stale_ids)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("failed to delete stale network configs: {:?}", e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_heartbeat(
|
||||
&self,
|
||||
req: HeartbeatRequest,
|
||||
@@ -98,20 +223,99 @@ impl SessionRpcService {
|
||||
req.machine_id
|
||||
))?;
|
||||
|
||||
let user_id = storage
|
||||
.db()
|
||||
.get_user_id_by_token(req.user_token.clone())
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Failed to get user id by token from db: {:?}",
|
||||
let (
|
||||
user_id,
|
||||
webhook_managed_network_configs,
|
||||
webhook_config_revision,
|
||||
webhook_validated,
|
||||
binding_version,
|
||||
) = if data.webhook_config.is_enabled() {
|
||||
let webhook_req = crate::webhook::ValidateTokenRequest {
|
||||
token: req.user_token.clone(),
|
||||
machine_id: machine_id.to_string(),
|
||||
public_ip: data.client_url.host_str().map(str::to_string),
|
||||
hostname: req.hostname.clone(),
|
||||
version: req.easytier_version.clone(),
|
||||
os_type: req.device_os.as_ref().map(|info| info.os_type.clone()),
|
||||
os_version: req.device_os.as_ref().map(|info| info.version.clone()),
|
||||
os_distribution: req.device_os.as_ref().map(|info| info.distribution.clone()),
|
||||
web_instance_id: data.webhook_config.web_instance_id.clone(),
|
||||
web_instance_api_base_url: data.webhook_config.web_instance_api_base_url.clone(),
|
||||
};
|
||||
let resp = data
|
||||
.webhook_config
|
||||
.validate_token(&webhook_req)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Webhook token validation failed: {:?}", e))?;
|
||||
|
||||
if resp.valid {
|
||||
let user_id = match storage
|
||||
.db()
|
||||
.get_user_id_by_token(req.user_token.clone())
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("DB error: {:?}", e))?
|
||||
{
|
||||
Some(id) => id,
|
||||
None => storage
|
||||
.auto_create_user(&req.user_token)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!("Failed to auto-create webhook user: {:?}", req.user_token)
|
||||
})?,
|
||||
};
|
||||
(
|
||||
user_id,
|
||||
resp.managed_network_configs,
|
||||
resp.config_revision,
|
||||
true,
|
||||
Some(resp.binding_version),
|
||||
)
|
||||
} else {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Webhook rejected token for machine {:?}: {:?}",
|
||||
machine_id,
|
||||
req.user_token
|
||||
)
|
||||
})?
|
||||
.ok_or(anyhow::anyhow!(
|
||||
"User not found by token: {:?}",
|
||||
req.user_token
|
||||
))?;
|
||||
.into());
|
||||
}
|
||||
} else {
|
||||
let user_id = match storage
|
||||
.db()
|
||||
.get_user_id_by_token(req.user_token.clone())
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Failed to get user id by token from db: {:?}",
|
||||
req.user_token
|
||||
)
|
||||
})? {
|
||||
Some(id) => id,
|
||||
None if data.feature_flags.allow_auto_create_user => storage
|
||||
.auto_create_user(&req.user_token)
|
||||
.await
|
||||
.with_context(|| format!("Failed to auto-create user: {:?}", req.user_token))?,
|
||||
None => {
|
||||
return Err(
|
||||
anyhow::anyhow!("User not found by token: {:?}", req.user_token).into(),
|
||||
);
|
||||
}
|
||||
};
|
||||
(user_id, Vec::new(), String::new(), false, None)
|
||||
};
|
||||
|
||||
if webhook_validated
|
||||
&& data.applied_config_revision.as_deref() != Some(webhook_config_revision.as_str())
|
||||
{
|
||||
Self::reconcile_managed_network_configs(
|
||||
&storage,
|
||||
user_id,
|
||||
machine_id,
|
||||
webhook_managed_network_configs,
|
||||
)
|
||||
.await
|
||||
.map_err(rpc_types::error::Error::from)?;
|
||||
data.applied_config_revision = Some(webhook_config_revision);
|
||||
}
|
||||
|
||||
if data.req.replace(req.clone()).is_none() {
|
||||
assert!(data.storage_token.is_none());
|
||||
@@ -121,6 +325,27 @@ impl SessionRpcService {
|
||||
machine_id,
|
||||
user_id,
|
||||
});
|
||||
data.binding_version = binding_version;
|
||||
|
||||
// Notify the webhook receiver on the first successful heartbeat.
|
||||
if data.webhook_config.is_enabled() {
|
||||
let webhook = data.webhook_config.clone();
|
||||
let connect_req = crate::webhook::NodeConnectedRequest {
|
||||
machine_id: machine_id.to_string(),
|
||||
token: req.user_token.clone(),
|
||||
user_id: Some(user_id),
|
||||
hostname: req.hostname.clone(),
|
||||
version: req.easytier_version.clone(),
|
||||
os_type: req.device_os.as_ref().map(|info| info.os_type.clone()),
|
||||
os_version: req.device_os.as_ref().map(|info| info.version.clone()),
|
||||
os_distribution: req.device_os.as_ref().map(|info| info.distribution.clone()),
|
||||
web_instance_id: webhook.web_instance_id.clone(),
|
||||
binding_version,
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
webhook.notify_node_connected(&connect_req).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let Ok(report_time) = chrono::DateTime::<chrono::Local>::from_str(&req.report_time) else {
|
||||
@@ -154,6 +379,16 @@ impl WebServerService for SessionRpcService {
|
||||
}
|
||||
ret
|
||||
}
|
||||
|
||||
async fn get_feature(
|
||||
&self,
|
||||
_: BaseController,
|
||||
_: easytier::proto::web::GetFeatureRequest,
|
||||
) -> rpc_types::error::Result<easytier::proto::web::GetFeatureResponse> {
|
||||
Ok(easytier::proto::web::GetFeatureResponse {
|
||||
support_encryption: easytier::web_client::security::web_secure_tunnel_supported(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Session {
|
||||
@@ -173,8 +408,15 @@ impl Debug for Session {
|
||||
type SessionRpcClient = Box<dyn WebClientService<Controller = BaseController> + Send>;
|
||||
|
||||
impl Session {
|
||||
pub fn new(storage: WeakRefStorage, client_url: url::Url, location: Option<Location>) -> Self {
|
||||
let session_data = SessionData::new(storage, client_url, location);
|
||||
pub fn new(
|
||||
storage: WeakRefStorage,
|
||||
client_url: url::Url,
|
||||
location: Option<Location>,
|
||||
feature_flags: Arc<FeatureFlags>,
|
||||
webhook_config: SharedWebhookConfig,
|
||||
) -> Self {
|
||||
let session_data =
|
||||
SessionData::new(storage, client_url, location, feature_flags, webhook_config);
|
||||
let data = Arc::new(RwLock::new(session_data));
|
||||
|
||||
let rpc_mgr =
|
||||
@@ -211,6 +453,8 @@ impl Session {
|
||||
storage: WeakRefStorage,
|
||||
rpc_client: SessionRpcClient,
|
||||
) {
|
||||
let mut cleaned_web_managed_instances = false;
|
||||
let mut last_desired_inst_ids: Option<HashSet<String>> = None;
|
||||
loop {
|
||||
heartbeat_waiter = heartbeat_waiter.resubscribe();
|
||||
let req = heartbeat_waiter.recv().await;
|
||||
@@ -232,7 +476,7 @@ impl Session {
|
||||
.running_network_instances
|
||||
.iter()
|
||||
.map(|x| x.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
.collect::<HashSet<_>>();
|
||||
let Some(storage) = storage.upgrade() else {
|
||||
tracing::error!("Failed to get storage");
|
||||
return;
|
||||
@@ -267,6 +511,63 @@ impl Session {
|
||||
};
|
||||
|
||||
let mut has_failed = false;
|
||||
let should_be_alive_inst_ids = local_configs
|
||||
.iter()
|
||||
.map(|cfg| cfg.network_instance_id.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
let desired_changed = last_desired_inst_ids
|
||||
.as_ref()
|
||||
.is_none_or(|last| last != &should_be_alive_inst_ids);
|
||||
|
||||
if !cleaned_web_managed_instances || desired_changed {
|
||||
let all_local_configs = match storage
|
||||
.db
|
||||
.list_network_configs((user_id, machine_id.into()), ListNetworkProps::All)
|
||||
.await
|
||||
{
|
||||
Ok(configs) => configs,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to list all network configs, error: {:?}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let all_inst_ids = all_local_configs
|
||||
.iter()
|
||||
.map(|cfg| cfg.network_instance_id.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
let should_delete_ids = running_inst_ids
|
||||
.iter()
|
||||
.chain(all_inst_ids.iter())
|
||||
.filter(|inst_id| !should_be_alive_inst_ids.contains(*inst_id))
|
||||
.filter_map(|inst_id| uuid::Uuid::parse_str(inst_id).ok())
|
||||
.map(Into::into)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if !should_delete_ids.is_empty() {
|
||||
let ret = rpc_client
|
||||
.delete_network_instance(
|
||||
BaseController::default(),
|
||||
easytier::proto::api::manage::DeleteNetworkInstanceRequest {
|
||||
inst_ids: should_delete_ids,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
tracing::info!(
|
||||
?user_id,
|
||||
"Clean non-web-managed network instances on start: {:?}, user_token: {:?}",
|
||||
ret,
|
||||
req.user_token
|
||||
);
|
||||
has_failed |= ret.is_err();
|
||||
}
|
||||
|
||||
if !has_failed {
|
||||
cleaned_web_managed_instances = true;
|
||||
last_desired_inst_ids = Some(should_be_alive_inst_ids.clone());
|
||||
}
|
||||
}
|
||||
|
||||
for c in local_configs {
|
||||
if running_inst_ids.contains(&c.network_instance_id) {
|
||||
@@ -295,8 +596,7 @@ impl Session {
|
||||
}
|
||||
|
||||
if !has_failed {
|
||||
tracing::info!(?req, "All network instances are running");
|
||||
break;
|
||||
last_desired_inst_ids = Some(should_be_alive_inst_ids);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -305,14 +605,22 @@ impl Session {
|
||||
self.rpc_mgr.is_running()
|
||||
}
|
||||
|
||||
pub async fn stop(&self) {
|
||||
self.rpc_mgr.stop().await;
|
||||
}
|
||||
|
||||
pub fn data(&self) -> SharedSessionData {
|
||||
self.data.clone()
|
||||
}
|
||||
|
||||
pub fn scoped_rpc_client(&self) -> SessionRpcClient {
|
||||
pub fn scoped_client<F: rpc_types::__rt::RpcClientFactory>(&self) -> F::ClientImpl {
|
||||
self.rpc_mgr
|
||||
.rpc_client()
|
||||
.scoped_client::<WebClientServiceClientFactory<BaseController>>(1, 1, "".to_string())
|
||||
.scoped_client::<F>(1, 1, "".to_string())
|
||||
}
|
||||
|
||||
pub fn scoped_rpc_client(&self) -> SessionRpcClient {
|
||||
self.scoped_client::<WebClientServiceClientFactory<BaseController>>()
|
||||
}
|
||||
|
||||
pub async fn get_token(&self) -> Option<StorageToken> {
|
||||
@@ -323,3 +631,103 @@ impl Session {
|
||||
self.data.read().await.req()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use easytier::rpc_service::remote_client::{ListNetworkProps, Storage as _};
|
||||
use serde_json::json;
|
||||
|
||||
use super::{super::storage::Storage, *};
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconcile_managed_network_configs_upserts_and_deletes_exact_set() {
|
||||
let storage = Storage::new(crate::db::Db::memory_db().await);
|
||||
let user_id = storage
|
||||
.db()
|
||||
.auto_create_user("webhook-user")
|
||||
.await
|
||||
.unwrap()
|
||||
.id;
|
||||
let machine_id = uuid::Uuid::new_v4();
|
||||
let keep_id = uuid::Uuid::new_v4();
|
||||
let stale_id = uuid::Uuid::new_v4();
|
||||
let new_id = uuid::Uuid::new_v4();
|
||||
|
||||
storage
|
||||
.db()
|
||||
.insert_or_update_user_network_config(
|
||||
(user_id, machine_id),
|
||||
keep_id,
|
||||
NetworkConfig {
|
||||
network_name: Some("old-name".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
storage
|
||||
.db()
|
||||
.insert_or_update_user_network_config(
|
||||
(user_id, machine_id),
|
||||
stale_id,
|
||||
NetworkConfig {
|
||||
network_name: Some("stale".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
SessionRpcService::reconcile_managed_network_configs(
|
||||
&storage,
|
||||
user_id,
|
||||
machine_id,
|
||||
vec![
|
||||
crate::webhook::ManagedNetworkConfig {
|
||||
instance_id: keep_id.to_string(),
|
||||
network_config: json!({
|
||||
"instance_id": keep_id.to_string(),
|
||||
"network_name": "updated-name"
|
||||
}),
|
||||
},
|
||||
crate::webhook::ManagedNetworkConfig {
|
||||
instance_id: new_id.to_string(),
|
||||
network_config: json!({
|
||||
"instance_id": new_id.to_string(),
|
||||
"network_name": "new-name"
|
||||
}),
|
||||
},
|
||||
],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let configs = storage
|
||||
.db()
|
||||
.list_network_configs((user_id, machine_id), ListNetworkProps::All)
|
||||
.await
|
||||
.unwrap();
|
||||
let config_ids = configs
|
||||
.iter()
|
||||
.map(|cfg| cfg.network_instance_id.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
assert_eq!(configs.len(), 2);
|
||||
assert!(config_ids.contains(&keep_id.to_string()));
|
||||
assert!(config_ids.contains(&new_id.to_string()));
|
||||
assert!(!config_ids.contains(&stale_id.to_string()));
|
||||
|
||||
let updated_keep = storage
|
||||
.db()
|
||||
.get_network_config((user_id, machine_id), &keep_id.to_string())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let updated_keep_config: NetworkConfig =
|
||||
serde_json::from_str(&updated_keep.network_config).unwrap();
|
||||
assert_eq!(
|
||||
updated_keep_config.network_name.as_deref(),
|
||||
Some("updated-name")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ struct ClientInfo {
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct StorageInner {
|
||||
// some map for indexing
|
||||
user_clients_map: DashMap<UserIdInDb, DashMap<uuid::Uuid, ClientInfo>>,
|
||||
pub db: Db,
|
||||
}
|
||||
@@ -46,18 +45,14 @@ impl Storage {
|
||||
}))
|
||||
}
|
||||
|
||||
fn remove_mid_to_client_info_map(
|
||||
map: &DashMap<uuid::Uuid, ClientInfo>,
|
||||
machine_id: &uuid::Uuid,
|
||||
client_url: &url::Url,
|
||||
) {
|
||||
map.remove_if(machine_id, |_, v| v.storage_token.client_url == *client_url);
|
||||
fn remove_client_info_map(map: &DashMap<uuid::Uuid, ClientInfo>, stoken: &StorageToken) {
|
||||
map.remove_if(&stoken.machine_id, |_, v| {
|
||||
v.storage_token.client_url == stoken.client_url
|
||||
&& v.storage_token.user_id == stoken.user_id
|
||||
});
|
||||
}
|
||||
|
||||
fn update_mid_to_client_info_map(
|
||||
map: &DashMap<uuid::Uuid, ClientInfo>,
|
||||
client_info: &ClientInfo,
|
||||
) {
|
||||
fn update_client_info_map(map: &DashMap<uuid::Uuid, ClientInfo>, client_info: &ClientInfo) {
|
||||
map.entry(client_info.storage_token.machine_id)
|
||||
.and_modify(|e| {
|
||||
if e.report_time < client_info.report_time {
|
||||
@@ -78,15 +73,14 @@ impl Storage {
|
||||
storage_token: stoken.clone(),
|
||||
report_time,
|
||||
};
|
||||
|
||||
Self::update_mid_to_client_info_map(&inner, &client_info);
|
||||
Self::update_client_info_map(&inner, &client_info);
|
||||
}
|
||||
|
||||
pub fn remove_client(&self, stoken: &StorageToken) {
|
||||
self.0
|
||||
.user_clients_map
|
||||
.remove_if(&stoken.user_id, |_, set| {
|
||||
Self::remove_mid_to_client_info_map(set, &stoken.machine_id, &stoken.client_url);
|
||||
Self::remove_client_info_map(set, stoken);
|
||||
set.is_empty()
|
||||
});
|
||||
}
|
||||
@@ -123,4 +117,61 @@ impl Storage {
|
||||
pub fn db(&self) -> &Db {
|
||||
&self.0.db
|
||||
}
|
||||
|
||||
pub async fn auto_create_user(&self, username: &str) -> anyhow::Result<UserIdInDb> {
|
||||
let new_user = self.db().auto_create_user(username).await?;
|
||||
tracing::info!("Auto-created user '{}' with id {}", username, new_user.id);
|
||||
Ok(new_user.id)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_storage_token(
|
||||
user_id: UserIdInDb,
|
||||
machine_id: uuid::Uuid,
|
||||
client_url: &str,
|
||||
) -> StorageToken {
|
||||
StorageToken {
|
||||
token: format!("token-{machine_id}"),
|
||||
client_url: client_url.parse().unwrap(),
|
||||
machine_id,
|
||||
user_id,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn machine_id_is_scoped_within_each_user() {
|
||||
let storage = Storage::new(Db::memory_db().await);
|
||||
let machine_id = uuid::Uuid::new_v4();
|
||||
|
||||
let user1_token = make_storage_token(1, machine_id, "tcp://127.0.0.1:1001");
|
||||
let user2_token = make_storage_token(2, machine_id, "tcp://127.0.0.1:1002");
|
||||
|
||||
storage.update_client(user1_token.clone(), 10);
|
||||
storage.update_client(user2_token.clone(), 20);
|
||||
|
||||
assert_eq!(
|
||||
storage.get_client_url_by_machine_id(1, &machine_id),
|
||||
Some(user1_token.client_url.clone())
|
||||
);
|
||||
assert_eq!(
|
||||
storage.get_client_url_by_machine_id(2, &machine_id),
|
||||
Some(user2_token.client_url.clone())
|
||||
);
|
||||
|
||||
storage.remove_client(&user1_token);
|
||||
|
||||
assert_eq!(storage.get_client_url_by_machine_id(1, &machine_id), None);
|
||||
assert_eq!(
|
||||
storage.get_client_url_by_machine_id(2, &machine_id),
|
||||
Some(user2_token.client_url.clone())
|
||||
);
|
||||
|
||||
storage.remove_client(&user2_token);
|
||||
|
||||
assert_eq!(storage.get_client_url_by_machine_id(2, &machine_id), None);
|
||||
}
|
||||
}
|
||||
|
||||
+126
-13
@@ -8,11 +8,11 @@ use easytier::{
|
||||
};
|
||||
use entity::user_running_network_configs;
|
||||
use sea_orm::{
|
||||
prelude::Expr, sea_query::OnConflict, ColumnTrait as _, DatabaseConnection, DbErr, EntityTrait,
|
||||
QueryFilter as _, SqlxSqliteConnector, TransactionTrait as _,
|
||||
ColumnTrait as _, DatabaseConnection, DbErr, EntityTrait, QueryFilter as _, Set,
|
||||
SqlxSqliteConnector, TransactionTrait as _, prelude::Expr, sea_query::OnConflict,
|
||||
};
|
||||
use sea_orm_migration::MigratorTrait as _;
|
||||
use sqlx::{migrate::MigrateDatabase as _, types::chrono, Sqlite, SqlitePool};
|
||||
use sqlx::{Sqlite, SqlitePool, migrate::MigrateDatabase as _, types::chrono};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::migrator;
|
||||
@@ -82,6 +82,57 @@ impl Db {
|
||||
Ok(user.map(|u| u.id))
|
||||
}
|
||||
|
||||
/// `password_hash` must be pre-hashed by the caller.
|
||||
/// Creates user + joins "users" group in one transaction. Returns the created user model.
|
||||
pub async fn create_user_and_join_users_group(
|
||||
&self,
|
||||
username: &str,
|
||||
password_hash: String,
|
||||
) -> Result<entity::users::Model, DbErr> {
|
||||
use entity::{groups, users, users_groups};
|
||||
|
||||
let txn = self.orm_db().begin().await?;
|
||||
|
||||
let user_active = users::ActiveModel {
|
||||
username: Set(username.to_string()),
|
||||
password: Set(password_hash),
|
||||
..Default::default()
|
||||
};
|
||||
let insert_result = users::Entity::insert(user_active).exec(&txn).await?;
|
||||
|
||||
let new_user = users::Entity::find_by_id(insert_result.last_insert_id)
|
||||
.one(&txn)
|
||||
.await?
|
||||
.ok_or_else(|| DbErr::Custom("Failed to find newly created user".to_string()))?;
|
||||
|
||||
let users_group = groups::Entity::find()
|
||||
.filter(groups::Column::Name.eq("users"))
|
||||
.one(&txn)
|
||||
.await?
|
||||
.ok_or_else(|| DbErr::Custom("Users group not found".to_string()))?;
|
||||
|
||||
let ug_active = users_groups::ActiveModel {
|
||||
user_id: Set(new_user.id),
|
||||
group_id: Set(users_group.id),
|
||||
..Default::default()
|
||||
};
|
||||
users_groups::Entity::insert(ug_active).exec(&txn).await?;
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
Ok(new_user)
|
||||
}
|
||||
|
||||
pub async fn auto_create_user(&self, username: &str) -> Result<entity::users::Model, DbErr> {
|
||||
let random_password = uuid::Uuid::new_v4().to_string();
|
||||
let hashed_password =
|
||||
tokio::task::spawn_blocking(move || password_auth::generate_hash(&random_password))
|
||||
.await
|
||||
.map_err(|e| DbErr::Custom(format!("Failed to hash password: {}", e)))?;
|
||||
self.create_user_and_join_users_group(username, hashed_password)
|
||||
.await
|
||||
}
|
||||
|
||||
// TODO: currently we don't have a token system, so we just use the user name as token
|
||||
pub async fn get_user_id_by_token<T: ToString>(
|
||||
&self,
|
||||
@@ -103,13 +154,17 @@ impl Storage<(UserIdInDb, Uuid), user_running_network_configs::Model, DbErr> for
|
||||
|
||||
use entity::user_running_network_configs as urnc;
|
||||
|
||||
let on_conflict = OnConflict::column(urnc::Column::NetworkInstanceId)
|
||||
.update_columns([
|
||||
urnc::Column::NetworkConfig,
|
||||
urnc::Column::Disabled,
|
||||
urnc::Column::UpdateTime,
|
||||
])
|
||||
.to_owned();
|
||||
let on_conflict = OnConflict::columns([
|
||||
urnc::Column::UserId,
|
||||
urnc::Column::DeviceId,
|
||||
urnc::Column::NetworkInstanceId,
|
||||
])
|
||||
.update_columns([
|
||||
urnc::Column::NetworkConfig,
|
||||
urnc::Column::Disabled,
|
||||
urnc::Column::UpdateTime,
|
||||
])
|
||||
.to_owned();
|
||||
let insert_m = urnc::ActiveModel {
|
||||
user_id: sea_orm::Set(user_id),
|
||||
device_id: sea_orm::Set(device_id.to_string()),
|
||||
@@ -133,13 +188,14 @@ impl Storage<(UserIdInDb, Uuid), user_running_network_configs::Model, DbErr> for
|
||||
|
||||
async fn delete_network_configs(
|
||||
&self,
|
||||
(user_id, _): (UserIdInDb, Uuid),
|
||||
(user_id, device_id): (UserIdInDb, Uuid),
|
||||
network_inst_ids: &[Uuid],
|
||||
) -> Result<(), DbErr> {
|
||||
use entity::user_running_network_configs as urnc;
|
||||
|
||||
urnc::Entity::delete_many()
|
||||
.filter(urnc::Column::UserId.eq(user_id))
|
||||
.filter(urnc::Column::DeviceId.eq(device_id.to_string()))
|
||||
.filter(
|
||||
urnc::Column::NetworkInstanceId
|
||||
.is_in(network_inst_ids.iter().map(|id| id.to_string())),
|
||||
@@ -152,7 +208,7 @@ impl Storage<(UserIdInDb, Uuid), user_running_network_configs::Model, DbErr> for
|
||||
|
||||
async fn update_network_config_state(
|
||||
&self,
|
||||
(user_id, _): (UserIdInDb, Uuid),
|
||||
(user_id, device_id): (UserIdInDb, Uuid),
|
||||
network_inst_id: Uuid,
|
||||
disabled: bool,
|
||||
) -> Result<(), DbErr> {
|
||||
@@ -160,6 +216,7 @@ impl Storage<(UserIdInDb, Uuid), user_running_network_configs::Model, DbErr> for
|
||||
|
||||
urnc::Entity::update_many()
|
||||
.filter(urnc::Column::UserId.eq(user_id))
|
||||
.filter(urnc::Column::DeviceId.eq(device_id.to_string()))
|
||||
.filter(urnc::Column::NetworkInstanceId.eq(network_inst_id.to_string()))
|
||||
.col_expr(urnc::Column::Disabled, Expr::value(disabled))
|
||||
.col_expr(
|
||||
@@ -223,7 +280,7 @@ mod tests {
|
||||
use easytier::{proto::api::manage::NetworkConfig, rpc_service::remote_client::Storage};
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter as _};
|
||||
|
||||
use crate::db::{entity::user_running_network_configs, Db, ListNetworkProps};
|
||||
use crate::db::{Db, ListNetworkProps, entity::user_running_network_configs};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_user_network_config_management() {
|
||||
@@ -290,4 +347,60 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(result3.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_user_network_config_same_instance_id_is_scoped_by_device() {
|
||||
let db = Db::memory_db().await;
|
||||
let user_id = db.auto_create_user("user-1").await.unwrap().id;
|
||||
let device1 = uuid::Uuid::new_v4();
|
||||
let device2 = uuid::Uuid::new_v4();
|
||||
let inst_id = uuid::Uuid::new_v4();
|
||||
|
||||
db.insert_or_update_user_network_config(
|
||||
(user_id, device1),
|
||||
inst_id,
|
||||
NetworkConfig {
|
||||
network_name: Some("cfg-1".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.insert_or_update_user_network_config(
|
||||
(user_id, device2),
|
||||
inst_id,
|
||||
NetworkConfig {
|
||||
network_name: Some("cfg-2".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let first = db
|
||||
.get_network_config((user_id, device1), &inst_id.to_string())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let second = db
|
||||
.get_network_config((user_id, device2), &inst_id.to_string())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(first.user_id, user_id);
|
||||
assert_eq!(first.device_id, device1.to_string());
|
||||
assert_eq!(second.user_id, user_id);
|
||||
assert_eq!(second.device_id, device2.to_string());
|
||||
|
||||
let device1_configs = db
|
||||
.list_network_configs((user_id, device1), ListNetworkProps::All)
|
||||
.await
|
||||
.unwrap();
|
||||
let device2_configs = db
|
||||
.list_network_configs((user_id, device2), ListNetworkProps::All)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(device1_configs.len(), 1);
|
||||
assert_eq!(device2_configs.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
+153
-27
@@ -3,28 +3,32 @@
|
||||
#[macro_use]
|
||||
extern crate rust_i18n;
|
||||
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use clap::Parser;
|
||||
use easytier::tunnel::websocket::WsTunnelListener;
|
||||
use easytier::{
|
||||
common::{
|
||||
config::{ConsoleLoggerConfig, FileLoggerConfig, LoggingConfigLoader},
|
||||
constants::EASYTIER_VERSION,
|
||||
error::Error,
|
||||
log,
|
||||
network::{local_ipv4, local_ipv6},
|
||||
},
|
||||
tunnel::{
|
||||
tcp::TcpTunnelListener, udp::UdpTunnelListener, websocket::WSTunnelListener, TunnelListener,
|
||||
},
|
||||
utils::{init_logger, setup_panic_handler},
|
||||
tunnel::{TunnelListener, tcp::TcpTunnelListener, udp::UdpTunnelListener},
|
||||
utils::panic::setup_panic_handler,
|
||||
};
|
||||
|
||||
use easytier::tunnel::IpScheme;
|
||||
use easytier::utils::BoxExt;
|
||||
use mimalloc::MiMalloc;
|
||||
|
||||
mod client_manager;
|
||||
mod db;
|
||||
mod migrator;
|
||||
mod restful;
|
||||
mod webhook;
|
||||
|
||||
#[cfg(feature = "embed")]
|
||||
mod web;
|
||||
@@ -82,6 +86,13 @@ struct Cli {
|
||||
)]
|
||||
api_server_port: u16,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "0.0.0.0",
|
||||
help = t!("cli.api_server_addr").to_string(),
|
||||
)]
|
||||
api_server_addr: IpAddr,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
help = t!("cli.geoip_db").to_string(),
|
||||
@@ -96,6 +107,14 @@ struct Cli {
|
||||
)]
|
||||
web_server_port: Option<u16>,
|
||||
|
||||
#[cfg(feature = "embed")]
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "0.0.0.0",
|
||||
help = t!("cli.web_server_addr").to_string(),
|
||||
)]
|
||||
web_server_addr: IpAddr,
|
||||
|
||||
#[cfg(feature = "embed")]
|
||||
#[arg(
|
||||
long,
|
||||
@@ -110,6 +129,51 @@ struct Cli {
|
||||
help = t!("cli.api_host").to_string()
|
||||
)]
|
||||
api_host: Option<url::Url>,
|
||||
|
||||
#[command(flatten)]
|
||||
feature_flags: FeatureFlags,
|
||||
|
||||
#[command(flatten)]
|
||||
oidc: restful::oidc::OidcOptions,
|
||||
|
||||
#[command(flatten)]
|
||||
webhook: WebhookOptions,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, clap::Args)]
|
||||
pub struct WebhookOptions {
|
||||
/// Base URL of the webhook endpoint for token validation and event delivery.
|
||||
/// When set, incoming tokens are validated via this webhook before local fallback.
|
||||
#[arg(long)]
|
||||
pub webhook_url: Option<String>,
|
||||
|
||||
/// Shared secret used to authenticate outbound webhook calls.
|
||||
#[arg(long)]
|
||||
pub webhook_secret: Option<String>,
|
||||
|
||||
/// Token for X-Internal-Auth header. When set, API requests with this header
|
||||
/// bypass session authentication.
|
||||
#[arg(long)]
|
||||
pub internal_auth_token: Option<String>,
|
||||
|
||||
/// Stable identifier for this easytier-web instance when routing webhook callbacks.
|
||||
#[arg(long)]
|
||||
pub web_instance_id: Option<String>,
|
||||
|
||||
/// Reachable base URL for this easytier-web instance's internal REST API.
|
||||
#[arg(long)]
|
||||
pub web_instance_api_base_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, clap::Args)]
|
||||
pub struct FeatureFlags {
|
||||
/// Whether user registration via the web UI is disabled.
|
||||
#[arg(long, default_value = "false", help = t!("cli.disable_registration").to_string())]
|
||||
pub disable_registration: bool,
|
||||
|
||||
/// Whether to auto-create users when they connect via heartbeat with an unknown token.
|
||||
#[arg(long, default_value = "false", help = t!("cli.allow_auto_create_user").to_string())]
|
||||
pub allow_auto_create_user: bool,
|
||||
}
|
||||
|
||||
impl LoggingConfigLoader for &Cli {
|
||||
@@ -130,14 +194,12 @@ impl LoggingConfigLoader for &Cli {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_listener_by_url(l: &url::Url) -> Result<Box<dyn TunnelListener>, Error> {
|
||||
Ok(match l.scheme() {
|
||||
"tcp" => Box::new(TcpTunnelListener::new(l.clone())),
|
||||
"udp" => Box::new(UdpTunnelListener::new(l.clone())),
|
||||
"ws" => Box::new(WSTunnelListener::new(l.clone())),
|
||||
_ => {
|
||||
return Err(Error::InvalidUrl(l.to_string()));
|
||||
}
|
||||
pub fn get_listener_by_url(scheme: IpScheme, l: &url::Url) -> Option<Box<dyn TunnelListener>> {
|
||||
Some(match scheme {
|
||||
IpScheme::Tcp => TcpTunnelListener::new(l.clone()).boxed(),
|
||||
IpScheme::Udp => UdpTunnelListener::new(l.clone()).boxed(),
|
||||
IpScheme::Ws => WsTunnelListener::new(l.clone()).boxed(),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -151,15 +213,23 @@ async fn get_dual_stack_listener(
|
||||
),
|
||||
Error,
|
||||
> {
|
||||
let is_protocol_support_dual_stack =
|
||||
protocol.trim().to_lowercase() == "tcp" || protocol.trim().to_lowercase() == "udp";
|
||||
let v6_listener = if is_protocol_support_dual_stack && local_ipv6().await.is_ok() {
|
||||
get_listener_by_url(&format!("{}://[::0]:{}", protocol, port).parse().unwrap()).ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let scheme = protocol
|
||||
.parse()
|
||||
.map_err(|_| Error::InvalidUrl(protocol.to_string()))?;
|
||||
let v6_listener =
|
||||
if local_ipv6().await.is_ok() && matches!(scheme, IpScheme::Tcp | IpScheme::Udp) {
|
||||
get_listener_by_url(
|
||||
scheme,
|
||||
&format!("{protocol}://[::]:{port}").parse().unwrap(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let v4_listener = if local_ipv4().await.is_ok() {
|
||||
get_listener_by_url(&format!("{}://0.0.0.0:{}", protocol, port).parse().unwrap()).ok()
|
||||
get_listener_by_url(
|
||||
scheme,
|
||||
&format!("{protocol}://0.0.0.0:{port}").parse().unwrap(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -173,11 +243,50 @@ async fn main() {
|
||||
setup_panic_handler();
|
||||
|
||||
let cli = Cli::parse();
|
||||
init_logger(&cli, false).unwrap();
|
||||
log::init(&cli, false).unwrap();
|
||||
|
||||
// Validate OIDC configuration: check split-deploy specific requirements
|
||||
// Basic OIDC parameter validation is handled in OidcConfig::from_params
|
||||
if cli.oidc.any_param_provided() {
|
||||
let is_split_deploy = {
|
||||
#[cfg(feature = "embed")]
|
||||
{
|
||||
let embed_split_by_port = cli.web_server_port.is_some()
|
||||
&& cli.web_server_port != Some(cli.api_server_port);
|
||||
cli.no_web || embed_split_by_port
|
||||
}
|
||||
#[cfg(not(feature = "embed"))]
|
||||
{
|
||||
true
|
||||
}
|
||||
};
|
||||
|
||||
if is_split_deploy && cli.oidc.oidc_frontend_base_url.is_none() {
|
||||
eprintln!("Error: --oidc-frontend-base-url is required in split-deploy mode");
|
||||
eprintln!(
|
||||
"When frontend and API are deployed separately, you must specify the frontend URL"
|
||||
);
|
||||
eprintln!("Example: --oidc-frontend-base-url http://your-frontend-domain.com");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// let db = db::Db::new(":memory:").await.unwrap();
|
||||
let db = db::Db::new(cli.db).await.unwrap();
|
||||
let mut mgr = client_manager::ClientManager::new(db.clone(), cli.geoip_db);
|
||||
let feature_flags = Arc::new(cli.feature_flags);
|
||||
let webhook_config = Arc::new(webhook::WebhookConfig::new(
|
||||
cli.webhook.webhook_url,
|
||||
cli.webhook.webhook_secret,
|
||||
cli.webhook.internal_auth_token,
|
||||
cli.webhook.web_instance_id,
|
||||
cli.webhook.web_instance_api_base_url,
|
||||
));
|
||||
let mut mgr = client_manager::ClientManager::new(
|
||||
db.clone(),
|
||||
cli.geoip_db,
|
||||
feature_flags.clone(),
|
||||
webhook_config.clone(),
|
||||
);
|
||||
let (v6_listener, v4_listener) =
|
||||
get_dual_stack_listener(&cli.config_server_protocol, cli.config_server_port)
|
||||
.await
|
||||
@@ -199,7 +308,10 @@ async fn main() {
|
||||
(None, None)
|
||||
} else {
|
||||
let web_router = web::build_router(cli.api_host.clone());
|
||||
if cli.web_server_port.is_none() || cli.web_server_port == Some(cli.api_server_port) {
|
||||
if cli.web_server_port.is_none()
|
||||
|| (cli.web_server_port == Some(cli.api_server_port)
|
||||
&& cli.web_server_addr == cli.api_server_addr)
|
||||
{
|
||||
(Some(web_router), None)
|
||||
} else {
|
||||
(None, Some(web_router))
|
||||
@@ -208,11 +320,27 @@ async fn main() {
|
||||
#[cfg(not(feature = "embed"))]
|
||||
let web_router_restful = None;
|
||||
|
||||
let oidc_config = if cli.oidc.oidc_issuer_url.is_some() {
|
||||
match restful::oidc::OidcConfig::from_params(cli.oidc).await {
|
||||
Ok(config) => config,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to initialize OIDC: {:?}", e);
|
||||
eprintln!("Please check your OIDC configuration (issuer URL, client ID, etc.)");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
restful::oidc::OidcConfig::disabled()
|
||||
};
|
||||
|
||||
let _restful_server_tasks = restful::RestfulServer::new(
|
||||
format!("0.0.0.0:{}", cli.api_server_port).parse().unwrap(),
|
||||
std::net::SocketAddr::new(cli.api_server_addr, cli.api_server_port),
|
||||
mgr.clone(),
|
||||
db,
|
||||
web_router_restful,
|
||||
feature_flags,
|
||||
oidc_config,
|
||||
webhook_config,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
@@ -224,9 +352,7 @@ async fn main() {
|
||||
let _web_server_task = if let Some(web_router) = web_router_static {
|
||||
Some(
|
||||
web::WebServer::new(
|
||||
format!("0.0.0.0:{}", cli.web_server_port.unwrap_or(0))
|
||||
.parse()
|
||||
.unwrap(),
|
||||
std::net::SocketAddr::new(cli.web_server_addr, cli.web_server_port.unwrap_or(0)),
|
||||
web_router,
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
pub struct Migration;
|
||||
|
||||
impl MigrationName for Migration {
|
||||
fn name(&self) -> &str {
|
||||
"m20260403_000002_scope_network_config_unique"
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = manager.get_connection();
|
||||
|
||||
db.execute_unprepared(
|
||||
r#"
|
||||
CREATE TABLE user_running_network_configs_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
network_instance_id TEXT NOT NULL,
|
||||
network_config TEXT NOT NULL,
|
||||
disabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
create_time TEXT NOT NULL,
|
||||
update_time TEXT NOT NULL,
|
||||
CONSTRAINT fk_user_running_network_configs_user_id_to_users_id
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO user_running_network_configs_new (
|
||||
id,
|
||||
user_id,
|
||||
device_id,
|
||||
network_instance_id,
|
||||
network_config,
|
||||
disabled,
|
||||
create_time,
|
||||
update_time
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
user_id,
|
||||
device_id,
|
||||
network_instance_id,
|
||||
network_config,
|
||||
disabled,
|
||||
create_time,
|
||||
update_time
|
||||
FROM user_running_network_configs;
|
||||
|
||||
DROP TABLE user_running_network_configs;
|
||||
ALTER TABLE user_running_network_configs_new RENAME TO user_running_network_configs;
|
||||
|
||||
CREATE INDEX idx_user_running_network_configs_user_id
|
||||
ON user_running_network_configs(user_id);
|
||||
CREATE UNIQUE INDEX idx_user_running_network_configs_scope_inst
|
||||
ON user_running_network_configs(user_id, device_id, network_instance_id);
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let db = manager.get_connection();
|
||||
|
||||
db.execute_unprepared(
|
||||
r#"
|
||||
CREATE TABLE user_running_network_configs_old (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
network_instance_id TEXT NOT NULL UNIQUE,
|
||||
network_config TEXT NOT NULL,
|
||||
disabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
create_time TEXT NOT NULL,
|
||||
update_time TEXT NOT NULL,
|
||||
CONSTRAINT fk_user_running_network_configs_user_id_to_users_id
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO user_running_network_configs_old (
|
||||
id,
|
||||
user_id,
|
||||
device_id,
|
||||
network_instance_id,
|
||||
network_config,
|
||||
disabled,
|
||||
create_time,
|
||||
update_time
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
user_id,
|
||||
device_id,
|
||||
network_instance_id,
|
||||
network_config,
|
||||
disabled,
|
||||
create_time,
|
||||
update_time
|
||||
FROM user_running_network_configs;
|
||||
|
||||
DROP TABLE user_running_network_configs;
|
||||
ALTER TABLE user_running_network_configs_old RENAME TO user_running_network_configs;
|
||||
|
||||
CREATE INDEX idx_user_running_network_configs_user_id
|
||||
ON user_running_network_configs(user_id);
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
mod m20241029_000001_init;
|
||||
mod m20260403_000002_scope_network_config_unique;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigratorTrait for Migrator {
|
||||
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
|
||||
vec![Box::new(m20241029_000001_init::Migration)]
|
||||
vec![
|
||||
Box::new(m20241029_000001_init::Migration),
|
||||
Box::new(m20260403_000002_scope_network_config_unique::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use axum::{
|
||||
Router,
|
||||
http::StatusCode,
|
||||
routing::{get, post, put},
|
||||
Router,
|
||||
};
|
||||
use axum_login::login_required;
|
||||
use axum_messages::Message;
|
||||
@@ -9,9 +9,13 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::restful::users::Backend;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::FeatureFlags;
|
||||
|
||||
use super::{
|
||||
users::{AuthSession, Credentials},
|
||||
AppStateInner,
|
||||
users::{AuthSession, Credentials},
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
@@ -40,7 +44,7 @@ mod put {
|
||||
use axum_login::AuthUser;
|
||||
use easytier::proto::common::Void;
|
||||
|
||||
use crate::restful::{other_error, users::ChangePassword, HttpHandleError};
|
||||
use crate::restful::{HttpHandleError, other_error, users::ChangePassword};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -67,14 +71,14 @@ mod put {
|
||||
}
|
||||
|
||||
mod post {
|
||||
use axum::Json;
|
||||
use axum::{Json, extract::Extension};
|
||||
use easytier::proto::common::Void;
|
||||
|
||||
use crate::restful::{
|
||||
captcha::extension::{axum_tower_sessions::CaptchaAxumTowerSessionStaticExt, CaptchaUtil},
|
||||
HttpHandleError,
|
||||
captcha::extension::{CaptchaUtil, axum_tower_sessions::CaptchaAxumTowerSessionStaticExt},
|
||||
other_error,
|
||||
users::RegisterNewUser,
|
||||
HttpHandleError,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
@@ -95,7 +99,7 @@ mod post {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json::from(other_error(format!("{:?}", e))),
|
||||
))
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -110,10 +114,20 @@ mod post {
|
||||
}
|
||||
|
||||
pub async fn register(
|
||||
Extension(feature_flags): Extension<Arc<FeatureFlags>>,
|
||||
auth_session: AuthSession,
|
||||
captcha_session: tower_sessions::Session,
|
||||
Json(req): Json<RegisterNewUser>,
|
||||
) -> Result<Json<Void>, HttpHandleError> {
|
||||
// Check if registration is disabled
|
||||
if feature_flags.disable_registration {
|
||||
tracing::warn!("Registration attempt blocked: registration is disabled");
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
other_error("Registration is disabled").into(),
|
||||
));
|
||||
}
|
||||
|
||||
// 调用CaptchaUtil的静态方法验证验证码是否正确
|
||||
if !CaptchaUtil::ver(&req.captcha, &captcha_session).await {
|
||||
return Err((
|
||||
@@ -136,14 +150,15 @@ mod post {
|
||||
|
||||
mod get {
|
||||
use crate::restful::{
|
||||
HttpHandleError,
|
||||
captcha::{
|
||||
builder::spec::SpecCaptcha,
|
||||
extension::{axum_tower_sessions::CaptchaAxumTowerSessionExt as _, CaptchaUtil},
|
||||
NewCaptcha as _,
|
||||
builder::spec::SpecCaptcha,
|
||||
extension::{CaptchaUtil, axum_tower_sessions::CaptchaAxumTowerSessionExt as _},
|
||||
},
|
||||
other_error, HttpHandleError,
|
||||
other_error,
|
||||
};
|
||||
use axum::{response::Response, Json};
|
||||
use axum::{Json, response::Response};
|
||||
use easytier::proto::common::Void;
|
||||
use tower_sessions::Session;
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ use super::super::base::randoms::Randoms;
|
||||
|
||||
use super::super::utils::color::Color;
|
||||
use super::super::utils::font;
|
||||
use base64::prelude::BASE64_STANDARD;
|
||||
use base64::Engine;
|
||||
use base64::prelude::BASE64_STANDARD;
|
||||
|
||||
use rusttype::Font;
|
||||
use std::fmt::Debug;
|
||||
|
||||
@@ -9,14 +9,14 @@ use super::super::{CaptchaFont, NewCaptcha};
|
||||
|
||||
use image::{ImageBuffer, Rgba};
|
||||
use imageproc::drawing;
|
||||
use rand::{rngs::ThreadRng, Rng};
|
||||
use rand::{Rng, rngs::ThreadRng};
|
||||
use rusttype::{Font, Scale};
|
||||
use std::io::{Cursor, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
mod color {
|
||||
use image::Rgba;
|
||||
use rand::{rngs::ThreadRng, Rng};
|
||||
use rand::{Rng, rngs::ThreadRng};
|
||||
pub fn gen_background_color(rng: &mut ThreadRng) -> Rgba<u8> {
|
||||
let red = rng.gen_range(200..=255);
|
||||
let green = rng.gen_range(200..=255);
|
||||
@@ -133,7 +133,7 @@ impl<'a, 'b> CaptchaBuilder<'a, 'b> {
|
||||
|
||||
fn draw_line(&self, image: &mut ImageBuffer<Rgba<u8>, Vec<u8>>, rng: &mut ThreadRng) {
|
||||
let line_color = color::gen_line_color(rng);
|
||||
let is_h = rng.gen();
|
||||
let is_h = rng.r#gen();
|
||||
let (start, end) = if is_h {
|
||||
let xa = rng.gen_range(0.0..(self.width as f32) / 2.0);
|
||||
let ya = rng.gen_range(0.0..(self.height as f32));
|
||||
|
||||
+106
-19
@@ -1,15 +1,20 @@
|
||||
mod auth;
|
||||
pub(crate) mod captcha;
|
||||
mod network;
|
||||
pub(crate) mod oidc;
|
||||
mod rpc;
|
||||
mod users;
|
||||
|
||||
use std::{net::SocketAddr, sync::Arc};
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::routing::post;
|
||||
use axum::{extract::State, routing::get, Json, Router};
|
||||
use axum::extract::Path;
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use axum::middleware::{self as axum_mw, Next};
|
||||
use axum::response::Response;
|
||||
use axum::routing::{delete, post};
|
||||
use axum::{Extension, Json, Router, extract::State, routing::get};
|
||||
use axum_login::tower_sessions::{ExpiredDeletion, SessionManagerLayer};
|
||||
use axum_login::{login_required, AuthManagerLayerBuilder, AuthUser, AuthzBackend};
|
||||
use axum_login::{AuthManagerLayerBuilder, AuthUser, AuthzBackend, login_required};
|
||||
use axum_messages::MessagesManagerLayer;
|
||||
use easytier::common::config::{ConfigLoader, TomlConfigLoader};
|
||||
use easytier::common::scoped_task::ScopedTask;
|
||||
@@ -18,15 +23,17 @@ use easytier::proto::rpc_types;
|
||||
use network::NetworkApi;
|
||||
use sea_orm::DbErr;
|
||||
use tokio::net::TcpListener;
|
||||
use tower_sessions::cookie::time::Duration;
|
||||
use tower_sessions::cookie::Key;
|
||||
use tower_sessions::Expiry;
|
||||
use tower_sessions::cookie::time::Duration;
|
||||
use tower_sessions::cookie::{Key, SameSite};
|
||||
use tower_sessions_sqlx_store::SqliteStore;
|
||||
use users::{AuthSession, Backend};
|
||||
|
||||
use crate::client_manager::storage::StorageToken;
|
||||
use crate::FeatureFlags;
|
||||
use crate::client_manager::ClientManager;
|
||||
use crate::db::Db;
|
||||
use crate::client_manager::storage::StorageToken;
|
||||
use crate::db::{Db, UserIdInDb};
|
||||
use crate::webhook::SharedWebhookConfig;
|
||||
|
||||
/// Embed assets for web dashboard, build frontend first
|
||||
#[cfg(feature = "embed")]
|
||||
@@ -37,11 +44,10 @@ struct Assets;
|
||||
pub struct RestfulServer {
|
||||
bind_addr: SocketAddr,
|
||||
client_mgr: Arc<ClientManager>,
|
||||
feature_flags: Arc<FeatureFlags>,
|
||||
webhook_config: SharedWebhookConfig,
|
||||
db: Db,
|
||||
|
||||
// serve_task: Option<ScopedTask<()>>,
|
||||
// delete_task: Option<ScopedTask<tower_sessions::session_store::Result<()>>>,
|
||||
// network_api: NetworkApi<WebClientManager>,
|
||||
oidc_config: oidc::OidcConfig,
|
||||
web_router: Option<Router>,
|
||||
}
|
||||
|
||||
@@ -104,18 +110,19 @@ impl RestfulServer {
|
||||
client_mgr: Arc<ClientManager>,
|
||||
db: Db,
|
||||
web_router: Option<Router>,
|
||||
feature_flags: Arc<FeatureFlags>,
|
||||
oidc_config: oidc::OidcConfig,
|
||||
webhook_config: SharedWebhookConfig,
|
||||
) -> anyhow::Result<Self> {
|
||||
assert!(client_mgr.is_running());
|
||||
|
||||
// let network_api = NetworkApi::new();
|
||||
|
||||
Ok(RestfulServer {
|
||||
bind_addr,
|
||||
client_mgr,
|
||||
feature_flags,
|
||||
webhook_config,
|
||||
db,
|
||||
// serve_task: None,
|
||||
// delete_task: None,
|
||||
// network_api,
|
||||
oidc_config,
|
||||
web_router,
|
||||
})
|
||||
}
|
||||
@@ -219,6 +226,7 @@ impl RestfulServer {
|
||||
|
||||
let session_layer = SessionManagerLayer::new(session_store)
|
||||
.with_secure(false)
|
||||
.with_same_site(SameSite::Lax)
|
||||
.with_expiry(Expiry::OnInactivity(Duration::days(1)))
|
||||
.with_signed(key);
|
||||
|
||||
@@ -235,23 +243,54 @@ impl RestfulServer {
|
||||
.zstd(true)
|
||||
.quality(tower_http::compression::CompressionLevel::Default);
|
||||
|
||||
let app = Router::new()
|
||||
// Token-authenticated management routes that bypass session auth.
|
||||
let internal_app = if self.webhook_config.has_internal_auth() {
|
||||
let internal_token = self.webhook_config.internal_auth_token.clone().unwrap();
|
||||
let internal_routes = Router::new()
|
||||
.route(
|
||||
"/api/internal/sessions",
|
||||
get(Self::handle_list_all_sessions_internal),
|
||||
)
|
||||
.route(
|
||||
"/api/internal/users/:user-id/sessions/:machine-id",
|
||||
delete(Self::handle_disconnect_session_internal),
|
||||
)
|
||||
.merge(NetworkApi::build_route_internal())
|
||||
.merge(rpc::router_internal())
|
||||
.with_state(self.client_mgr.clone())
|
||||
.layer(axum_mw::from_fn(move |req, next| {
|
||||
let token = internal_token.clone();
|
||||
internal_auth_middleware(token, req, next)
|
||||
}));
|
||||
Some(internal_routes)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut app = Router::new()
|
||||
.route("/api/v1/summary", get(Self::handle_get_summary))
|
||||
.route("/api/v1/sessions", get(Self::handle_list_all_sessions))
|
||||
.merge(NetworkApi::build_route())
|
||||
.merge(rpc::router())
|
||||
.route_layer(login_required!(Backend))
|
||||
.merge(auth::router())
|
||||
.merge(auth::router().layer(Extension(self.feature_flags.clone())))
|
||||
.merge(oidc::router())
|
||||
.with_state(self.client_mgr.clone())
|
||||
.route(
|
||||
"/api/v1/generate-config",
|
||||
post(Self::handle_generate_config),
|
||||
)
|
||||
.route("/api/v1/parse-config", post(Self::handle_parse_config))
|
||||
.layer(Extension(self.oidc_config.clone()))
|
||||
.layer(MessagesManagerLayer)
|
||||
.layer(auth_layer)
|
||||
.layer(tower_http::cors::CorsLayer::very_permissive())
|
||||
.layer(compression_layer);
|
||||
|
||||
if let Some(internal_routes) = internal_app {
|
||||
app = app.merge(internal_routes);
|
||||
}
|
||||
|
||||
#[cfg(feature = "embed")]
|
||||
let app = if let Some(web_router) = self.web_router.take() {
|
||||
app.merge(web_router)
|
||||
@@ -266,4 +305,52 @@ impl RestfulServer {
|
||||
|
||||
Ok((serve_task, delete_task))
|
||||
}
|
||||
|
||||
/// Session listing endpoint for token-authenticated management clients.
|
||||
async fn handle_list_all_sessions_internal(
|
||||
State(client_mgr): AppState,
|
||||
) -> Result<Json<ListSessionJsonResp>, HttpHandleError> {
|
||||
let ret = client_mgr.list_sessions().await;
|
||||
Ok(ListSessionJsonResp(ret).into())
|
||||
}
|
||||
|
||||
async fn handle_disconnect_session_internal(
|
||||
Path((user_id, machine_id)): Path<(UserIdInDb, uuid::Uuid)>,
|
||||
State(client_mgr): AppState,
|
||||
) -> Result<StatusCode, HttpHandleError> {
|
||||
if client_mgr
|
||||
.disconnect_session_by_machine_id(user_id, &machine_id)
|
||||
.await
|
||||
{
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
} else {
|
||||
Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
other_error("session not found").into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Middleware that validates X-Internal-Auth for token-authenticated routes.
|
||||
async fn internal_auth_middleware(
|
||||
expected_token: String,
|
||||
req: Request<axum::body::Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let auth_header = req
|
||||
.headers()
|
||||
.get("X-Internal-Auth")
|
||||
.and_then(|v| v.to_str().ok());
|
||||
|
||||
match auth_header {
|
||||
Some(token) if token == expected_token => next.run(req).await,
|
||||
_ => Response::builder()
|
||||
.status(StatusCode::UNAUTHORIZED)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(axum::body::Body::from(
|
||||
r#"{"error":"unauthorized: invalid or missing X-Internal-Auth header"}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use axum::extract::Path;
|
||||
use axum::http::StatusCode;
|
||||
use axum::routing::{delete, post};
|
||||
use axum::{extract::State, routing::get, Json, Router};
|
||||
use axum::{Json, Router, extract::State, routing::get};
|
||||
use axum_login::AuthUser;
|
||||
use easytier::launcher::NetworkConfig;
|
||||
use easytier::proto::common::Void;
|
||||
@@ -16,7 +16,7 @@ use crate::db::UserIdInDb;
|
||||
|
||||
use super::users::AuthSession;
|
||||
use super::{
|
||||
convert_db_error, other_error, AppState, AppStateInner, Error, HttpHandleError, RpcError,
|
||||
AppState, AppStateInner, Error, HttpHandleError, RpcError, convert_db_error, other_error,
|
||||
};
|
||||
|
||||
fn convert_rpc_error(e: RpcError) -> (StatusCode, Json<Error>) {
|
||||
@@ -295,6 +295,70 @@ impl NetworkApi {
|
||||
.into())
|
||||
}
|
||||
|
||||
// --- Token-authenticated machine-scoped handlers (no AuthSession) ---
|
||||
|
||||
async fn handle_run_network_instance_internal(
|
||||
State(client_mgr): AppState,
|
||||
Path((user_id, machine_id)): Path<(UserIdInDb, uuid::Uuid)>,
|
||||
Json(payload): Json<RunNetworkJsonReq>,
|
||||
) -> Result<Json<Void>, HttpHandleError> {
|
||||
client_mgr
|
||||
.handle_run_network_instance((user_id, machine_id), payload.config, payload.save)
|
||||
.await
|
||||
.map_err(convert_error)?;
|
||||
Ok(Void::default().into())
|
||||
}
|
||||
|
||||
async fn handle_remove_network_instance_internal(
|
||||
State(client_mgr): AppState,
|
||||
Path((user_id, machine_id, inst_id)): Path<(UserIdInDb, uuid::Uuid, uuid::Uuid)>,
|
||||
) -> Result<(), HttpHandleError> {
|
||||
client_mgr
|
||||
.handle_remove_network_instances((user_id, machine_id), vec![inst_id])
|
||||
.await
|
||||
.map_err(convert_error)
|
||||
}
|
||||
|
||||
async fn handle_list_network_instance_ids_internal(
|
||||
State(client_mgr): AppState,
|
||||
Path((user_id, machine_id)): Path<(UserIdInDb, uuid::Uuid)>,
|
||||
) -> Result<Json<ListNetworkInstanceIdsJsonResp>, HttpHandleError> {
|
||||
Ok(client_mgr
|
||||
.handle_list_network_instance_ids((user_id, machine_id))
|
||||
.await
|
||||
.map_err(convert_error)?
|
||||
.into())
|
||||
}
|
||||
|
||||
async fn handle_collect_network_info_internal(
|
||||
State(client_mgr): AppState,
|
||||
Path((user_id, machine_id)): Path<(UserIdInDb, uuid::Uuid)>,
|
||||
Json(payload): Json<CollectNetworkInfoJsonReq>,
|
||||
) -> Result<Json<CollectNetworkInfoResponse>, HttpHandleError> {
|
||||
Ok(client_mgr
|
||||
.handle_collect_network_info((user_id, machine_id), payload.inst_ids)
|
||||
.await
|
||||
.map_err(convert_error)?
|
||||
.into())
|
||||
}
|
||||
|
||||
pub fn build_route_internal() -> Router<AppStateInner> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/internal/users/:user-id/machines/:machine-id/networks",
|
||||
post(Self::handle_run_network_instance_internal)
|
||||
.get(Self::handle_list_network_instance_ids_internal),
|
||||
)
|
||||
.route(
|
||||
"/api/internal/users/:user-id/machines/:machine-id/networks/:inst-id",
|
||||
delete(Self::handle_remove_network_instance_internal),
|
||||
)
|
||||
.route(
|
||||
"/api/internal/users/:user-id/machines/:machine-id/networks/info",
|
||||
get(Self::handle_collect_network_info_internal),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_route() -> Router<AppStateInner> {
|
||||
Router::new()
|
||||
.route("/api/v1/machines", get(Self::handle_list_machines))
|
||||
|
||||
@@ -0,0 +1,735 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
use axum::Router;
|
||||
use axum::routing::get;
|
||||
use openidconnect::core::{
|
||||
CoreAuthDisplay, CoreAuthPrompt, CoreErrorResponseType, CoreGenderClaim, CoreJsonWebKey,
|
||||
CoreJweContentEncryptionAlgorithm, CoreJwsSigningAlgorithm, CoreProviderMetadata,
|
||||
CoreRevocableToken, CoreRevocationErrorResponse, CoreTokenIntrospectionResponse, CoreTokenType,
|
||||
};
|
||||
use openidconnect::{
|
||||
Client, ClientId, ClientSecret, EmptyExtraTokenFields, EndpointMaybeSet, EndpointNotSet,
|
||||
EndpointSet, IdTokenFields, IssuerUrl, RedirectUrl, StandardErrorResponse,
|
||||
StandardTokenResponse,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::AppStateInner;
|
||||
|
||||
const DEFAULT_OIDC_SCOPES: [&str; 2] = ["openid", "profile"];
|
||||
|
||||
fn normalize_oidc_scopes(scopes: &[String]) -> Vec<String> {
|
||||
let mut normalized: Vec<String> = scopes
|
||||
.iter()
|
||||
.map(|scope| scope.trim().to_string())
|
||||
.filter(|scope| !scope.is_empty())
|
||||
.collect();
|
||||
|
||||
if normalized.is_empty() {
|
||||
normalized = DEFAULT_OIDC_SCOPES
|
||||
.iter()
|
||||
.map(|scope| scope.to_string())
|
||||
.collect();
|
||||
}
|
||||
|
||||
if !normalized.iter().any(|scope| scope == "openid") {
|
||||
normalized.insert(0, "openid".to_string());
|
||||
}
|
||||
|
||||
normalized
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct JsonAdditionalClaims {
|
||||
#[serde(flatten)]
|
||||
pub claims: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
impl openidconnect::AdditionalClaims for JsonAdditionalClaims {}
|
||||
|
||||
pub type AppIdTokenFields = IdTokenFields<
|
||||
JsonAdditionalClaims,
|
||||
EmptyExtraTokenFields,
|
||||
CoreGenderClaim,
|
||||
CoreJweContentEncryptionAlgorithm,
|
||||
CoreJwsSigningAlgorithm,
|
||||
>;
|
||||
|
||||
pub type AppTokenResponse = StandardTokenResponse<AppIdTokenFields, CoreTokenType>;
|
||||
|
||||
pub type AppClient<
|
||||
HasAuthUrl = EndpointNotSet,
|
||||
HasDeviceAuthUrl = EndpointNotSet,
|
||||
HasIntrospectionUrl = EndpointNotSet,
|
||||
HasRevocationUrl = EndpointNotSet,
|
||||
HasTokenUrl = EndpointNotSet,
|
||||
HasUserInfoUrl = EndpointNotSet,
|
||||
> = Client<
|
||||
JsonAdditionalClaims,
|
||||
CoreAuthDisplay,
|
||||
CoreGenderClaim,
|
||||
CoreJweContentEncryptionAlgorithm,
|
||||
CoreJsonWebKey,
|
||||
CoreAuthPrompt,
|
||||
StandardErrorResponse<CoreErrorResponseType>,
|
||||
AppTokenResponse,
|
||||
CoreTokenIntrospectionResponse,
|
||||
CoreRevocableToken,
|
||||
CoreRevocationErrorResponse,
|
||||
HasAuthUrl,
|
||||
HasDeviceAuthUrl,
|
||||
HasIntrospectionUrl,
|
||||
HasRevocationUrl,
|
||||
HasTokenUrl,
|
||||
HasUserInfoUrl,
|
||||
>;
|
||||
|
||||
pub type ConfiguredAppClient = AppClient<
|
||||
EndpointSet,
|
||||
EndpointNotSet,
|
||||
EndpointNotSet,
|
||||
EndpointNotSet,
|
||||
EndpointMaybeSet,
|
||||
EndpointMaybeSet,
|
||||
>;
|
||||
|
||||
/// Convert a dot-path (e.g. `realm_access.roles.0`) to a JSON Pointer (e.g. `/realm_access/roles/0`).
|
||||
/// Each segment is escaped per RFC 6901: `~` → `~0`, `/` → `~1`.
|
||||
fn dot_path_to_json_pointer(dot_path: &str) -> String {
|
||||
let mut pointer = String::new();
|
||||
for segment in dot_path.split('.') {
|
||||
pointer.push('/');
|
||||
for ch in segment.chars() {
|
||||
match ch {
|
||||
'~' => pointer.push_str("~0"),
|
||||
'/' => pointer.push_str("~1"),
|
||||
_ => pointer.push(ch),
|
||||
}
|
||||
}
|
||||
}
|
||||
pointer
|
||||
}
|
||||
|
||||
/// Timing-safe string comparison via constant-time equality check.
|
||||
/// Prevents timing side-channel attacks on CSRF token verification.
|
||||
fn timing_safe_eq(a: &str, b: &str) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
a.as_bytes().ct_eq(b.as_bytes()).into()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, clap::Args)]
|
||||
pub struct OidcOptions {
|
||||
#[arg(long, help = t!("cli.oidc_issuer_url").to_string())]
|
||||
pub oidc_issuer_url: Option<String>,
|
||||
|
||||
#[arg(long, help = t!("cli.oidc_client_id").to_string())]
|
||||
pub oidc_client_id: Option<String>,
|
||||
|
||||
#[arg(long, env = "OIDC_CLIENT_SECRET", help = t!("cli.oidc_client_secret").to_string())]
|
||||
pub oidc_client_secret: Option<String>,
|
||||
|
||||
#[arg(long, default_value = "preferred_username", help = t!("cli.oidc_username_claim").to_string())]
|
||||
pub oidc_username_claim: String,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
value_delimiter = ',',
|
||||
default_values = DEFAULT_OIDC_SCOPES,
|
||||
help = t!("cli.oidc_scopes").to_string()
|
||||
)]
|
||||
pub oidc_scopes: Vec<String>,
|
||||
|
||||
#[arg(long, help = t!("cli.oidc_redirect_url").to_string())]
|
||||
pub oidc_redirect_url: Option<String>,
|
||||
|
||||
#[arg(long, default_value = "false", help = t!("cli.oidc_disable_pkce").to_string())]
|
||||
pub oidc_disable_pkce: bool,
|
||||
|
||||
#[arg(long, help = t!("cli.oidc_frontend_base_url").to_string())]
|
||||
pub oidc_frontend_base_url: Option<String>,
|
||||
}
|
||||
|
||||
impl OidcOptions {
|
||||
pub fn any_param_provided(&self) -> bool {
|
||||
self.oidc_issuer_url.is_some()
|
||||
|| self.oidc_client_id.is_some()
|
||||
|| self.oidc_client_secret.is_some()
|
||||
|| self.oidc_redirect_url.is_some()
|
||||
|| self.oidc_frontend_base_url.is_some()
|
||||
|| self.oidc_username_claim != "preferred_username"
|
||||
|| self.oidc_scopes != DEFAULT_OIDC_SCOPES
|
||||
|| self.oidc_disable_pkce
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct OidcConfig {
|
||||
pub enabled: bool,
|
||||
pub provider_metadata: Option<Arc<CoreProviderMetadata>>,
|
||||
pub client_id: String,
|
||||
pub client_secret: Option<String>,
|
||||
pub redirect_url: Option<RedirectUrl>,
|
||||
pub username_claim: String,
|
||||
pub scopes: Vec<String>,
|
||||
pub pkce_enabled: bool,
|
||||
pub frontend_base_url: Option<String>,
|
||||
pub http_client: Option<reqwest::Client>,
|
||||
cached_client: Option<Arc<ConfiguredAppClient>>,
|
||||
}
|
||||
|
||||
impl OidcConfig {
|
||||
pub fn disabled() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
provider_metadata: None,
|
||||
client_id: String::new(),
|
||||
client_secret: None,
|
||||
redirect_url: None,
|
||||
username_claim: "preferred_username".to_string(),
|
||||
scopes: DEFAULT_OIDC_SCOPES
|
||||
.iter()
|
||||
.map(|scope| scope.to_string())
|
||||
.collect(),
|
||||
pkce_enabled: false,
|
||||
frontend_base_url: None,
|
||||
http_client: None,
|
||||
cached_client: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn from_params(opts: OidcOptions) -> anyhow::Result<Self> {
|
||||
let OidcOptions {
|
||||
oidc_issuer_url,
|
||||
oidc_client_id,
|
||||
oidc_client_secret,
|
||||
oidc_username_claim,
|
||||
oidc_scopes,
|
||||
oidc_redirect_url,
|
||||
oidc_disable_pkce,
|
||||
oidc_frontend_base_url,
|
||||
} = opts;
|
||||
|
||||
if oidc_issuer_url.is_none() || oidc_client_id.is_none() || oidc_redirect_url.is_none() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"--oidc-issuer-url, --oidc-client-id and --oidc-redirect-url are required when using OIDC authentication"
|
||||
));
|
||||
}
|
||||
if oidc_username_claim.trim().is_empty() {
|
||||
return Err(anyhow::anyhow!("--oidc-username-claim cannot be empty"));
|
||||
}
|
||||
let http_client = reqwest::ClientBuilder::new()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()?;
|
||||
|
||||
let issuer_url = oidc_issuer_url.ok_or_else(|| {
|
||||
anyhow::anyhow!("--oidc-issuer-url is required when using OIDC authentication")
|
||||
})?;
|
||||
|
||||
let provider_metadata =
|
||||
CoreProviderMetadata::discover_async(IssuerUrl::new(issuer_url)?, &http_client).await?;
|
||||
|
||||
let client_id = oidc_client_id.ok_or_else(|| {
|
||||
anyhow::anyhow!("--oidc-client-id is required when using OIDC authentication")
|
||||
})?;
|
||||
|
||||
let redirect_url = oidc_redirect_url
|
||||
.ok_or_else(|| anyhow::anyhow!("--oidc-redirect-url is required when using OIDC authentication. The redirect URL must match exactly what is registered with your Identity Provider. Example: --oidc-redirect-url http://your-domain.com:11211/api/v1/auth/oidc/callback"))?;
|
||||
|
||||
let provider_metadata = Arc::new(provider_metadata);
|
||||
let redirect_url = RedirectUrl::new(redirect_url)?;
|
||||
let client_secret = oidc_client_secret;
|
||||
|
||||
let cached_client = {
|
||||
let c = AppClient::from_provider_metadata(
|
||||
provider_metadata.as_ref().clone(),
|
||||
ClientId::new(client_id.clone()),
|
||||
client_secret.as_ref().map(|s| ClientSecret::new(s.clone())),
|
||||
)
|
||||
.set_redirect_uri(redirect_url.clone());
|
||||
Arc::new(c)
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
enabled: true,
|
||||
provider_metadata: Some(provider_metadata),
|
||||
client_id,
|
||||
client_secret,
|
||||
redirect_url: Some(redirect_url),
|
||||
username_claim: oidc_username_claim,
|
||||
scopes: normalize_oidc_scopes(&oidc_scopes),
|
||||
pkce_enabled: !oidc_disable_pkce,
|
||||
frontend_base_url: oidc_frontend_base_url,
|
||||
http_client: Some(http_client),
|
||||
cached_client: Some(cached_client),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn client(&self) -> Option<&ConfiguredAppClient> {
|
||||
self.cached_client.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn router() -> Router<AppStateInner> {
|
||||
Router::new()
|
||||
.route("/api/v1/auth/oidc/config", get(self::route::oidc_config))
|
||||
.route("/api/v1/auth/oidc/login", get(self::route::oidc_login))
|
||||
.route(
|
||||
"/api/v1/auth/oidc/callback",
|
||||
get(self::route::oidc_callback),
|
||||
)
|
||||
}
|
||||
|
||||
mod route {
|
||||
use axum::extract::Query;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Redirect, Response};
|
||||
use axum::{Extension, Json};
|
||||
use openidconnect::core::CoreAuthenticationFlow;
|
||||
use openidconnect::{
|
||||
AccessTokenHash, AuthorizationCode, CsrfToken, Nonce, OAuth2TokenResponse,
|
||||
PkceCodeChallenge, PkceCodeVerifier, Scope, TokenResponse,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::restful::other_error;
|
||||
use crate::restful::users::AuthSession;
|
||||
|
||||
use super::OidcConfig;
|
||||
|
||||
pub async fn oidc_config(Extension(oidc): Extension<OidcConfig>) -> Json<serde_json::Value> {
|
||||
Json(serde_json::json!({ "enabled": oidc.enabled }))
|
||||
}
|
||||
|
||||
pub async fn oidc_login(
|
||||
Extension(oidc): Extension<OidcConfig>,
|
||||
session: tower_sessions::Session,
|
||||
) -> Response {
|
||||
if !oidc.enabled {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(other_error("OIDC is not enabled")),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let client = match oidc.client() {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(other_error("OIDC client not initialized")),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let scopes = oidc.scopes.clone();
|
||||
let pkce_enabled = oidc.pkce_enabled;
|
||||
|
||||
let (pkce_challenge, pkce_verifier) = if pkce_enabled {
|
||||
let (challenge, verifier) = PkceCodeChallenge::new_random_sha256();
|
||||
(Some(challenge), Some(verifier))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
let mut auth_request = client.authorize_url(
|
||||
CoreAuthenticationFlow::AuthorizationCode,
|
||||
CsrfToken::new_random,
|
||||
Nonce::new_random,
|
||||
);
|
||||
|
||||
for scope in &scopes {
|
||||
auth_request = auth_request.add_scope(Scope::new(scope.clone()));
|
||||
}
|
||||
|
||||
if let Some(challenge) = pkce_challenge {
|
||||
auth_request = auth_request.set_pkce_challenge(challenge);
|
||||
}
|
||||
|
||||
let (auth_url, csrf_token, nonce) = auth_request.url();
|
||||
|
||||
if let Err(e) = session
|
||||
.insert("oidc_csrf_token", csrf_token.secret().clone())
|
||||
.await
|
||||
{
|
||||
tracing::error!("Failed to store csrf_token in session: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(other_error("Session error")),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if let Err(e) = session.insert("oidc_nonce", nonce.secret().clone()).await {
|
||||
tracing::error!("Failed to store nonce in session: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(other_error("Session error")),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if let Some(verifier) = pkce_verifier
|
||||
&& let Err(e) = session
|
||||
.insert("oidc_pkce_verifier", verifier.secret().clone())
|
||||
.await
|
||||
{
|
||||
tracing::error!("Failed to store pkce_verifier in session: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(other_error("Session error")),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if let Err(e) = session.insert("oidc_pkce_used", pkce_enabled).await {
|
||||
tracing::error!("Failed to store pkce_used in session: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(other_error("Session error")),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
Redirect::temporary(auth_url.as_str()).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CallbackParams {
|
||||
code: Option<String>,
|
||||
state: Option<String>,
|
||||
error: Option<String>,
|
||||
error_description: Option<String>,
|
||||
}
|
||||
|
||||
async fn cleanup_oidc_session(session: &tower_sessions::Session) {
|
||||
let _ = session.remove::<String>("oidc_csrf_token").await;
|
||||
let _ = session.remove::<String>("oidc_nonce").await;
|
||||
let _ = session.remove::<String>("oidc_pkce_verifier").await;
|
||||
let _ = session.remove::<bool>("oidc_pkce_used").await;
|
||||
}
|
||||
|
||||
pub async fn oidc_callback(
|
||||
Extension(oidc): Extension<OidcConfig>,
|
||||
Query(params): Query<CallbackParams>,
|
||||
session: tower_sessions::Session,
|
||||
mut auth_session: AuthSession,
|
||||
) -> Response {
|
||||
if !oidc.enabled {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(other_error("OIDC is not enabled")),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Some(ref error) = params.error {
|
||||
tracing::error!(
|
||||
"OIDC provider returned error: {}, description: {:?}",
|
||||
error,
|
||||
params.error_description
|
||||
);
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(other_error(
|
||||
"Authentication failed at the identity provider",
|
||||
)),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let code = match params.code {
|
||||
Some(ref c) => c.clone(),
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(other_error("Missing authorization code")),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let callback_state = match params.state {
|
||||
Some(ref s) => s.clone(),
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(other_error("Missing state parameter in callback")),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let stored_csrf: String = match session.get("oidc_csrf_token").await {
|
||||
Ok(Some(v)) => v,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(other_error("Missing or invalid CSRF token in session")),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
if !super::timing_safe_eq(&stored_csrf, &callback_state) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(other_error("CSRF state mismatch")),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let stored_nonce: String = match session.get("oidc_nonce").await {
|
||||
Ok(Some(v)) => v,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(other_error("Missing nonce in session")),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let stored_pkce_verifier: Option<String> =
|
||||
session.get("oidc_pkce_verifier").await.ok().flatten();
|
||||
let pkce_was_used: Option<bool> = session.get("oidc_pkce_used").await.ok().flatten();
|
||||
|
||||
cleanup_oidc_session(&session).await;
|
||||
|
||||
let client = match oidc.client() {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(other_error("OIDC client not initialized")),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let http_client = match oidc.http_client.as_ref() {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
tracing::error!("HTTP client not initialized in OIDC config");
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(other_error("OIDC internal error")),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let mut token_request = match client.exchange_code(AuthorizationCode::new(code)) {
|
||||
Ok(req) => req,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to create token request: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(other_error("Failed to create token exchange request")),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(stored_pkce_verifier) = stored_pkce_verifier {
|
||||
token_request =
|
||||
token_request.set_pkce_verifier(PkceCodeVerifier::new(stored_pkce_verifier));
|
||||
} else if pkce_was_used == Some(true) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(other_error(
|
||||
"PKCE was enabled but verifier is missing from session (session may have expired)",
|
||||
)),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let token_response = match token_request.request_async(http_client).await {
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to exchange code for token: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(other_error("Token exchange failed")),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let id_token = match token_response.id_token() {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(other_error("No ID token in response")),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let claims = match id_token.claims(&client.id_token_verifier(), &Nonce::new(stored_nonce)) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to verify ID token: {:?}", e);
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(other_error("ID token verification failed")),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(expected_at_hash) = claims.access_token_hash() {
|
||||
let id_token_verifier = client.id_token_verifier();
|
||||
let (Ok(signing_alg), Ok(signing_key)) = (
|
||||
id_token.signing_alg(),
|
||||
id_token.signing_key(&id_token_verifier),
|
||||
) else {
|
||||
tracing::error!("Failed to get signing algorithm or key for at_hash verification");
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(other_error("Failed to determine token signing algorithm")),
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
|
||||
let actual_at_hash = match AccessTokenHash::from_token(
|
||||
token_response.access_token(),
|
||||
signing_alg,
|
||||
signing_key,
|
||||
) {
|
||||
Ok(hash) => hash,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to compute access token hash: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(other_error("Failed to verify access token hash")),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if actual_at_hash != *expected_at_hash {
|
||||
tracing::error!("Access token hash mismatch");
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(other_error("Access token hash mismatch")),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let claims_json = match serde_json::to_value(claims) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to serialize claims: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(other_error("Failed to process ID token claims")),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let pointer = super::dot_path_to_json_pointer(&oidc.username_claim);
|
||||
let username: Option<String> = claims_json
|
||||
.pointer(&pointer)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let username = match username {
|
||||
Some(u) if !u.is_empty() => u,
|
||||
_ => {
|
||||
tracing::error!(
|
||||
"Could not extract username from claim '{}' in token",
|
||||
oidc.username_claim
|
||||
);
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(other_error("Could not extract username from token claims")),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let user = match auth_session
|
||||
.backend
|
||||
.find_or_create_oidc_user(&username)
|
||||
.await
|
||||
{
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to find or create OIDC user '{}': {:?}", username, e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(other_error("Failed to provision user account")),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = auth_session.login(&user).await {
|
||||
tracing::error!("Failed to login user via OIDC: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(other_error("Failed to establish session")),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Err(e) = session.cycle_id().await {
|
||||
tracing::error!("Failed to cycle session ID after OIDC login: {:?}", e);
|
||||
}
|
||||
if let Some(frontend_url) = &oidc.frontend_base_url {
|
||||
Redirect::temporary(frontend_url).into_response()
|
||||
} else {
|
||||
Redirect::temporary("/").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_dot_path_to_json_pointer() {
|
||||
use serde_json::json;
|
||||
|
||||
let cases = vec![
|
||||
(
|
||||
"realm_access.roles.0",
|
||||
"/realm_access/roles/0",
|
||||
json!({ "realm_access": { "roles": ["admin", "user"] } }),
|
||||
"admin",
|
||||
),
|
||||
(
|
||||
"preferred_username",
|
||||
"/preferred_username",
|
||||
json!({ "preferred_username": "bob" }),
|
||||
"bob",
|
||||
),
|
||||
("a~b.c", "/a~0b/c", json!({ "a~b": { "c": "v" } }), "v"),
|
||||
("a/b.c", "/a~1b/c", json!({ "a/b": { "c": "w" } }), "w"),
|
||||
("~/.x", "/~0~1/x", json!({ "~/": { "x": "z" } }), "z"),
|
||||
("a..b", "/a//b", json!({ "a": { "": { "b": "x" } } }), "x"),
|
||||
("", "/", json!({ "": "root" }), "root"),
|
||||
];
|
||||
|
||||
for (path, expected_ptr, json_val, expected_val) in cases {
|
||||
let ptr = dot_path_to_json_pointer(path);
|
||||
assert_eq!(ptr, expected_ptr, "Pointer mismatch for path: {}", path);
|
||||
assert_eq!(
|
||||
json_val.pointer(&ptr).and_then(|v| v.as_str()),
|
||||
Some(expected_val),
|
||||
"Value extraction failed for path: {}, pointer: {}",
|
||||
path,
|
||||
ptr
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
routing::post,
|
||||
};
|
||||
use axum_login::AuthUser as _;
|
||||
use easytier::proto::rpc_types::controller::BaseController;
|
||||
|
||||
use crate::db::UserIdInDb;
|
||||
|
||||
use super::{AppState, HttpHandleError, other_error};
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct ProxyRpcRequest {
|
||||
pub service_name: String,
|
||||
pub method_name: String,
|
||||
pub payload: serde_json::Value,
|
||||
}
|
||||
|
||||
macro_rules! match_service {
|
||||
($factory:ty, $method_name:expr, $payload:expr, $session:expr) => {{
|
||||
let client = $session.scoped_client::<$factory>();
|
||||
client
|
||||
.json_call_method(BaseController::default(), &$method_name, $payload)
|
||||
.await
|
||||
}};
|
||||
}
|
||||
|
||||
async fn handle_proxy_rpc_by_session(
|
||||
session: &crate::client_manager::session::Session,
|
||||
req: ProxyRpcRequest,
|
||||
) -> Result<Json<serde_json::Value>, HttpHandleError> {
|
||||
let ProxyRpcRequest {
|
||||
service_name,
|
||||
method_name,
|
||||
payload,
|
||||
} = req;
|
||||
|
||||
let resp = match service_name.as_str() {
|
||||
"api.manage.WebClientService" => match_service!(
|
||||
easytier::proto::api::manage::WebClientServiceClientFactory<BaseController>,
|
||||
method_name,
|
||||
payload,
|
||||
session
|
||||
),
|
||||
"api.instance.PeerManageRpcService" => match_service!(
|
||||
easytier::proto::api::instance::PeerManageRpcClientFactory<BaseController>,
|
||||
method_name,
|
||||
payload,
|
||||
session
|
||||
),
|
||||
"api.instance.PeerCenterManageRpcService" => match_service!(
|
||||
easytier::proto::peer_rpc::PeerCenterRpcClientFactory<BaseController>,
|
||||
method_name,
|
||||
payload,
|
||||
session
|
||||
),
|
||||
"api.instance.ConnectorManageRpcService" => match_service!(
|
||||
easytier::proto::api::instance::ConnectorManageRpcClientFactory<BaseController>,
|
||||
method_name,
|
||||
payload,
|
||||
session
|
||||
),
|
||||
"api.instance.MappedListenerManageRpcService" => match_service!(
|
||||
easytier::proto::api::instance::MappedListenerManageRpcClientFactory<BaseController>,
|
||||
method_name,
|
||||
payload,
|
||||
session
|
||||
),
|
||||
"api.instance.VpnPortalRpcService" => match_service!(
|
||||
easytier::proto::api::instance::VpnPortalRpcClientFactory<BaseController>,
|
||||
method_name,
|
||||
payload,
|
||||
session
|
||||
),
|
||||
"api.instance.TcpProxyRpcService" => match_service!(
|
||||
easytier::proto::api::instance::TcpProxyRpcClientFactory<BaseController>,
|
||||
method_name,
|
||||
payload,
|
||||
session
|
||||
),
|
||||
"api.instance.AclManageRpcService" => match_service!(
|
||||
easytier::proto::api::instance::AclManageRpcClientFactory<BaseController>,
|
||||
method_name,
|
||||
payload,
|
||||
session
|
||||
),
|
||||
"api.instance.PortForwardManageRpcService" => match_service!(
|
||||
easytier::proto::api::instance::PortForwardManageRpcClientFactory<BaseController>,
|
||||
method_name,
|
||||
payload,
|
||||
session
|
||||
),
|
||||
"api.instance.StatsRpcService" => match_service!(
|
||||
easytier::proto::api::instance::StatsRpcClientFactory<BaseController>,
|
||||
method_name,
|
||||
payload,
|
||||
session
|
||||
),
|
||||
"api.instance.CredentialManageRpcService" => match_service!(
|
||||
easytier::proto::api::instance::CredentialManageRpcClientFactory<BaseController>,
|
||||
method_name,
|
||||
payload,
|
||||
session
|
||||
),
|
||||
"api.logger.LoggerRpcService" => match_service!(
|
||||
easytier::proto::api::logger::LoggerRpcClientFactory<BaseController>,
|
||||
method_name,
|
||||
payload,
|
||||
session
|
||||
),
|
||||
"api.config.ConfigRpcService" => match_service!(
|
||||
easytier::proto::api::config::ConfigRpcClientFactory<BaseController>,
|
||||
method_name,
|
||||
payload,
|
||||
session
|
||||
),
|
||||
_ => {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
other_error(format!("Unknown service: {}", service_name)).into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
match resp {
|
||||
Ok(v) => Ok(Json(v)),
|
||||
Err(e) => Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
other_error(format!("RPC Error: {:?}", e)).into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_proxy_rpc(
|
||||
auth_session: super::users::AuthSession,
|
||||
State(client_mgr): AppState,
|
||||
Path(machine_id): Path<uuid::Uuid>,
|
||||
Json(req): Json<ProxyRpcRequest>,
|
||||
) -> Result<Json<serde_json::Value>, HttpHandleError> {
|
||||
let user_id = auth_session
|
||||
.user
|
||||
.as_ref()
|
||||
.ok_or((StatusCode::UNAUTHORIZED, other_error("Unauthorized").into()))?
|
||||
.id();
|
||||
|
||||
let session = client_mgr
|
||||
.get_session_by_machine_id(user_id, &machine_id)
|
||||
.ok_or((
|
||||
StatusCode::NOT_FOUND,
|
||||
other_error("Session not found").into(),
|
||||
))?;
|
||||
handle_proxy_rpc_by_session(session.as_ref(), req).await
|
||||
}
|
||||
|
||||
pub fn router() -> Router<super::AppStateInner> {
|
||||
Router::new().route(
|
||||
"/api/v1/machines/:machine-id/proxy-rpc",
|
||||
post(handle_proxy_rpc),
|
||||
)
|
||||
}
|
||||
|
||||
/// Internal proxy-rpc handler: no AuthSession, resolves the active session by machine_id.
|
||||
pub async fn handle_proxy_rpc_internal(
|
||||
State(client_mgr): AppState,
|
||||
Path((user_id, machine_id)): Path<(UserIdInDb, uuid::Uuid)>,
|
||||
Json(req): Json<ProxyRpcRequest>,
|
||||
) -> Result<Json<serde_json::Value>, HttpHandleError> {
|
||||
let session = client_mgr
|
||||
.get_session_by_machine_id(user_id, &machine_id)
|
||||
.ok_or((
|
||||
StatusCode::NOT_FOUND,
|
||||
other_error("Session not found").into(),
|
||||
))?;
|
||||
handle_proxy_rpc_by_session(session.as_ref(), req).await
|
||||
}
|
||||
|
||||
pub fn router_internal() -> Router<super::AppStateInner> {
|
||||
Router::new().route(
|
||||
"/api/internal/users/:user-id/machines/:machine-id/proxy-rpc",
|
||||
post(handle_proxy_rpc_internal),
|
||||
)
|
||||
}
|
||||
@@ -4,8 +4,8 @@ use async_trait::async_trait;
|
||||
use axum_login::{AuthUser, AuthnBackend, AuthzBackend, UserId};
|
||||
use password_auth::verify_password;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait as _, ColumnTrait, EntityTrait, FromQueryResult, IntoActiveModel, JoinType,
|
||||
QueryFilter, QuerySelect as _, RelationTrait, Set, TransactionTrait,
|
||||
ColumnTrait, EntityTrait, FromQueryResult, IntoActiveModel, JoinType, QueryFilter,
|
||||
QuerySelect as _, RelationTrait, Set,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::task;
|
||||
@@ -14,7 +14,7 @@ use crate::db::{self, entity};
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct User {
|
||||
db_user: entity::users::Model,
|
||||
pub(crate) db_user: entity::users::Model,
|
||||
pub tokens: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -39,9 +39,9 @@ impl AuthUser for User {
|
||||
|
||||
fn session_auth_hash(&self) -> &[u8] {
|
||||
self.db_user.password.as_bytes() // We use the password hash as the auth
|
||||
// hash--what this means
|
||||
// is when the user changes their password the
|
||||
// auth session becomes invalid.
|
||||
// hash--what this means
|
||||
// is when the user changes their password the
|
||||
// auth session becomes invalid.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,40 +74,47 @@ impl Backend {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
pub fn db(&self) -> &db::Db {
|
||||
&self.db
|
||||
}
|
||||
|
||||
pub async fn register_new_user(&self, new_user: &RegisterNewUser) -> anyhow::Result<()> {
|
||||
let hashed_password = password_auth::generate_hash(new_user.credentials.password.as_str());
|
||||
let txn = self.db.orm_db().begin().await?;
|
||||
|
||||
entity::users::ActiveModel {
|
||||
username: Set(new_user.credentials.username.clone()),
|
||||
password: Set(hashed_password.clone()),
|
||||
..Default::default()
|
||||
}
|
||||
.save(&txn)
|
||||
.await?;
|
||||
|
||||
entity::users_groups::ActiveModel {
|
||||
user_id: Set(entity::users::Entity::find()
|
||||
.filter(entity::users::Column::Username.eq(new_user.credentials.username.as_str()))
|
||||
.one(&txn)
|
||||
.await?
|
||||
.unwrap()
|
||||
.id),
|
||||
group_id: Set(entity::groups::Entity::find()
|
||||
.filter(entity::groups::Column::Name.eq("users"))
|
||||
.one(&txn)
|
||||
.await?
|
||||
.unwrap()
|
||||
.id),
|
||||
..Default::default()
|
||||
}
|
||||
.save(&txn)
|
||||
.await?;
|
||||
txn.commit().await?;
|
||||
|
||||
self.db
|
||||
.create_user_and_join_users_group(&new_user.credentials.username, hashed_password)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Find a user by username, or auto-create one for OIDC-authenticated users.
|
||||
///
|
||||
/// Unlike the heartbeat auto-creation path (controlled by `allow_auto_create_user`),
|
||||
/// OIDC users are always provisioned automatically because their identity has already
|
||||
/// been verified by a trusted external Identity Provider (IdP).
|
||||
pub async fn find_or_create_oidc_user(&self, username: &str) -> anyhow::Result<User> {
|
||||
use entity::users;
|
||||
|
||||
// Try to find an existing user first.
|
||||
if let Some(db_user) = users::Entity::find()
|
||||
.filter(users::Column::Username.eq(username))
|
||||
.one(self.db.orm_db())
|
||||
.await?
|
||||
{
|
||||
return Ok(User {
|
||||
tokens: vec![db_user.username.clone()],
|
||||
db_user,
|
||||
});
|
||||
}
|
||||
|
||||
// User not found – auto-provision a local account backed by the IdP identity.
|
||||
let db_user = self.db.auto_create_user(username).await?;
|
||||
tracing::info!("Auto-provisioned OIDC user '{username}'");
|
||||
Ok(User {
|
||||
tokens: vec![db_user.username.clone()],
|
||||
db_user,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn change_password(
|
||||
&self,
|
||||
id: <User as AuthUser>::Id,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use axum::{
|
||||
Router,
|
||||
extract::State,
|
||||
http::header,
|
||||
response::{IntoResponse, Response},
|
||||
routing, Router,
|
||||
routing,
|
||||
};
|
||||
use axum_embed::ServeEmbed;
|
||||
use easytier::common::scoped_task::ScopedTask;
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Webhook configuration for external integrations.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WebhookConfig {
|
||||
pub webhook_url: Option<String>,
|
||||
pub webhook_secret: Option<String>,
|
||||
pub internal_auth_token: Option<String>,
|
||||
pub web_instance_id: Option<String>,
|
||||
pub web_instance_api_base_url: Option<String>,
|
||||
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl WebhookConfig {
|
||||
pub fn new(
|
||||
webhook_url: Option<String>,
|
||||
webhook_secret: Option<String>,
|
||||
internal_auth_token: Option<String>,
|
||||
web_instance_id: Option<String>,
|
||||
web_instance_api_base_url: Option<String>,
|
||||
) -> Self {
|
||||
WebhookConfig {
|
||||
webhook_url,
|
||||
webhook_secret,
|
||||
internal_auth_token,
|
||||
web_instance_id,
|
||||
web_instance_api_base_url,
|
||||
client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.webhook_url
|
||||
.as_deref()
|
||||
.is_some_and(|url| !url.trim().is_empty())
|
||||
}
|
||||
|
||||
pub fn has_internal_auth(&self) -> bool {
|
||||
self.internal_auth_token.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Request/Response types ---
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ValidateTokenRequest {
|
||||
pub token: String,
|
||||
pub machine_id: String,
|
||||
pub public_ip: Option<String>,
|
||||
pub hostname: String,
|
||||
pub version: String,
|
||||
pub os_type: Option<String>,
|
||||
pub os_version: Option<String>,
|
||||
pub os_distribution: Option<String>,
|
||||
pub web_instance_id: Option<String>,
|
||||
pub web_instance_api_base_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ValidateTokenResponse {
|
||||
pub valid: bool,
|
||||
#[serde(default)]
|
||||
pub pre_approved: bool,
|
||||
#[serde(default)]
|
||||
pub binding_version: u64,
|
||||
pub managed_network_configs: Vec<ManagedNetworkConfig>,
|
||||
pub config_revision: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ManagedNetworkConfig {
|
||||
pub instance_id: String,
|
||||
pub network_config: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct NodeConnectedRequest {
|
||||
pub machine_id: String,
|
||||
pub token: String,
|
||||
pub user_id: Option<i32>,
|
||||
pub hostname: String,
|
||||
pub version: String,
|
||||
pub os_type: Option<String>,
|
||||
pub os_version: Option<String>,
|
||||
pub os_distribution: Option<String>,
|
||||
pub web_instance_id: Option<String>,
|
||||
pub binding_version: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct NodeDisconnectedRequest {
|
||||
pub machine_id: String,
|
||||
pub token: String,
|
||||
pub user_id: Option<i32>,
|
||||
pub web_instance_id: Option<String>,
|
||||
pub binding_version: Option<u64>,
|
||||
}
|
||||
|
||||
// --- Webhook client ---
|
||||
|
||||
impl WebhookConfig {
|
||||
fn webhook_base_url(&self) -> anyhow::Result<&str> {
|
||||
self.webhook_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|url| !url.is_empty())
|
||||
.ok_or_else(|| anyhow::anyhow!("webhook_url is not configured"))
|
||||
}
|
||||
|
||||
fn webhook_endpoint(&self, path: &str) -> anyhow::Result<String> {
|
||||
Ok(format!(
|
||||
"{}/{}",
|
||||
self.webhook_base_url()?.trim_end_matches('/'),
|
||||
path.trim_start_matches('/'),
|
||||
))
|
||||
}
|
||||
|
||||
/// Validate a token through the configured webhook endpoint.
|
||||
pub async fn validate_token(
|
||||
&self,
|
||||
req: &ValidateTokenRequest,
|
||||
) -> anyhow::Result<ValidateTokenResponse> {
|
||||
let url = self.webhook_endpoint("validate-token")?;
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("X-Internal-Auth", self.webhook_auth_secret())
|
||||
.json(req)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("webhook validate-token returned status {}", resp.status());
|
||||
}
|
||||
|
||||
Ok(resp.json().await?)
|
||||
}
|
||||
|
||||
/// Notify the webhook receiver that a node has connected.
|
||||
pub async fn notify_node_connected(&self, req: &NodeConnectedRequest) {
|
||||
if !self.is_enabled() {
|
||||
return;
|
||||
}
|
||||
let Ok(url) = self.webhook_endpoint("webhook/node-connected") else {
|
||||
tracing::warn!("skip node-connected webhook because webhook_url is not configured");
|
||||
return;
|
||||
};
|
||||
let _ = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("X-Internal-Auth", self.webhook_auth_secret())
|
||||
.json(req)
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Notify the webhook receiver that a node has disconnected.
|
||||
pub async fn notify_node_disconnected(&self, req: &NodeDisconnectedRequest) {
|
||||
if !self.is_enabled() {
|
||||
return;
|
||||
}
|
||||
let Ok(url) = self.webhook_endpoint("webhook/node-disconnected") else {
|
||||
tracing::warn!("skip node-disconnected webhook because webhook_url is not configured");
|
||||
return;
|
||||
};
|
||||
let _ = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("X-Internal-Auth", self.webhook_auth_secret())
|
||||
.json(req)
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
|
||||
fn webhook_auth_secret(&self) -> &str {
|
||||
self.webhook_secret
|
||||
.as_deref()
|
||||
.or(self.internal_auth_token.as_deref())
|
||||
.unwrap_or("")
|
||||
}
|
||||
}
|
||||
|
||||
pub type SharedWebhookConfig = Arc<WebhookConfig>;
|
||||
@@ -0,0 +1,11 @@
|
||||
disallowed-methods = [
|
||||
{ path = "itertools::Itertools::map_into", reason = "Blocks underlying iterator optimizations. Use the native `.map(Into::into)` instead." },
|
||||
{ path = "itertools::Itertools::map_ok", reason = "Blocks underlying iterator optimizations. Use the native `.map(|r| r.map(f))` instead." },
|
||||
{ path = "itertools::Itertools::filter_ok", reason = "Blocks underlying iterator optimizations. Use a native approach, e.g., `.filter(|r| r.as_ref().map_or(true, condition))`." },
|
||||
{ path = "itertools::Itertools::filter_map_ok", reason = "Blocks underlying iterator optimizations. Use native `.map()` and `.flatten()`, or extract logic into a standard `.filter_map()`." },
|
||||
|
||||
{ path = "itertools::Itertools::collect_vec", reason = "Non-standard idiom. Directly use the standard library's `.collect::<Vec<_>>()`." },
|
||||
{ path = "itertools::Itertools::try_collect", reason = "Non-standard idiom. Standard `collect()` already supports Result/Option inversion; use `.collect::<Result<_, _>>()`." },
|
||||
{ path = "itertools::Itertools::set_from", reason = "Non-standard idiom. Directly use the `.extend()` method provided by the standard library's `Extend` trait." },
|
||||
{ path = "itertools::Itertools::concat", reason = "Non-standard idiom. Use native `.flatten().collect()` or a slice's `.concat()` instead." }
|
||||
]
|
||||
+98
-37
@@ -3,12 +3,12 @@ name = "easytier"
|
||||
description = "A full meshed p2p VPN, connecting all your devices in one network with one command."
|
||||
homepage = "https://github.com/EasyTier/EasyTier"
|
||||
repository = "https://github.com/EasyTier/EasyTier"
|
||||
version = "2.5.0"
|
||||
edition = "2021"
|
||||
version = "2.6.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
authors = ["kkrainbow"]
|
||||
keywords = ["vpn", "p2p", "network", "easytier"]
|
||||
categories = ["network-programming", "command-line-utilities"]
|
||||
rust-version = "1.89.0"
|
||||
license-file = "LICENSE"
|
||||
readme = "README.md"
|
||||
|
||||
@@ -36,7 +36,12 @@ tracing-subscriber = { version = "0.3", features = [
|
||||
"local-time",
|
||||
"time",
|
||||
] }
|
||||
derivative = "2.2.0"
|
||||
derive_more = { version = "2.1.1", features = ["full"] }
|
||||
console-subscriber = { version = "0.4.1", optional = true }
|
||||
indoc = "2.0.7"
|
||||
regex = "1.8"
|
||||
paste = "1.0"
|
||||
thiserror = "1.0"
|
||||
auto_impl = "1.1.0"
|
||||
crossbeam = "0.8.4"
|
||||
@@ -45,6 +50,12 @@ time = "0.3"
|
||||
toml = "0.8.12"
|
||||
chrono = { version = "0.4.37", features = ["serde"] }
|
||||
|
||||
delegate = "0.13.5"
|
||||
|
||||
itertools = "0.14.0"
|
||||
|
||||
strum = { version = "0.27.2", features = ["derive"] }
|
||||
|
||||
gethostname = "0.5.0"
|
||||
|
||||
futures = { version = "0.3", features = ["bilock", "unstable"] }
|
||||
@@ -64,15 +75,18 @@ zerocopy = { version = "0.7.32", features = ["derive", "simd"] }
|
||||
bytes = "1.5.0"
|
||||
pin-project-lite = "0.2.13"
|
||||
|
||||
atomic_refcell = "0.1.13"
|
||||
|
||||
quinn = { version = "0.11.8", optional = true, features = ["ring"] }
|
||||
quinn-plaintext = { version = "0.3.0", optional = true }
|
||||
|
||||
rustls = { version = "0.23.0", features = [
|
||||
"ring",
|
||||
"ring", "tls12"
|
||||
], default-features = false, optional = true }
|
||||
rcgen = { version = "0.12.1", optional = true }
|
||||
|
||||
# for websocket
|
||||
tokio-websockets = { version = "0.8", optional = true, features = [
|
||||
tokio-websockets = { version = "0.13.2", optional = true, features = [
|
||||
"rustls-webpki-roots",
|
||||
"client",
|
||||
"server",
|
||||
@@ -82,14 +96,21 @@ tokio-websockets = { version = "0.8", optional = true, features = [
|
||||
http = { version = "1", default-features = false, features = [
|
||||
"std",
|
||||
], optional = true }
|
||||
forwarded-header-value = { version = "0.1.1", optional = true }
|
||||
tokio-rustls = { version = "0.26", default-features = false, optional = true }
|
||||
|
||||
# for tap device
|
||||
tun = { package = "tun-easytier", git="https://github.com/EasyTier/rust-tun", features = [
|
||||
tun = { package = "tun-easytier", git = "https://github.com/EasyTier/rust-tun", features = [
|
||||
"async",
|
||||
], optional = true }
|
||||
# for net ns
|
||||
nix = { version = "0.29.0", features = ["sched", "socket", "ioctl", "net", "fs"] }
|
||||
nix = { version = "0.29.0", features = [
|
||||
"sched",
|
||||
"socket",
|
||||
"ioctl",
|
||||
"net",
|
||||
"fs",
|
||||
] }
|
||||
|
||||
uuid = { version = "1.5.0", features = [
|
||||
"v4",
|
||||
@@ -102,8 +123,9 @@ uuid = { version = "1.5.0", features = [
|
||||
once_cell = "1.18.0"
|
||||
|
||||
# for rpc
|
||||
prost = "0.13"
|
||||
prost-types = "0.13"
|
||||
prost = "0.13.5"
|
||||
prost-wkt = "0.6"
|
||||
prost-wkt-types = "0.6"
|
||||
anyhow = "1.0"
|
||||
|
||||
url = { version = "2.5", features = ["serde"] }
|
||||
@@ -135,6 +157,7 @@ clap = { version = "4.5.30", features = [
|
||||
"env",
|
||||
] }
|
||||
clap_complete = { version = "4.5.55" }
|
||||
clap_complete_nushell = { version = "4.5.10" }
|
||||
|
||||
async-recursion = "1.0.5"
|
||||
|
||||
@@ -142,7 +165,6 @@ network-interface = "2.0"
|
||||
|
||||
# for ospf route
|
||||
petgraph = "0.8.1"
|
||||
hashbrown = "0.15.3"
|
||||
ordered_hash_map = "0.5.0"
|
||||
|
||||
# for wireguard
|
||||
@@ -153,10 +175,14 @@ ring = { version = "0.17", optional = true }
|
||||
bitflags = "2.5"
|
||||
aes-gcm = { version = "0.10.3", optional = true }
|
||||
openssl = { version = "0.10", optional = true, features = ["vendored"] }
|
||||
snow = "0.10.0"
|
||||
x25519-dalek = { version = "2.0", features = ["static_secrets"] }
|
||||
|
||||
# for cli
|
||||
tabled = "0.16"
|
||||
humansize = "2.1.3"
|
||||
terminal_size = "0.4"
|
||||
unicode-width = "0.1"
|
||||
|
||||
base64 = "0.22"
|
||||
|
||||
@@ -180,7 +206,7 @@ smoltcp = { git = "https://github.com/smoltcp-rs/smoltcp.git", rev = "0a926767a6
|
||||
# "socket-tcp-cubic",
|
||||
"async",
|
||||
] }
|
||||
parking_lot = { version = "0.12.0", optional = true }
|
||||
parking_lot = { version = "0.12.0" }
|
||||
|
||||
wildmatch = "2.3.4"
|
||||
|
||||
@@ -192,9 +218,9 @@ async-ringbuf = "0.3.1"
|
||||
|
||||
service-manager = { git = "https://github.com/EasyTier/service-manager-rs.git", branch = "main" }
|
||||
|
||||
zstd = { version = "0.13" }
|
||||
zstd = { version = "0.13", optional = true }
|
||||
|
||||
kcp-sys = { git = "https://github.com/EasyTier/kcp-sys", rev = "71eff18c573a4a71bf99c7fabc6a8b9f211c84c1" }
|
||||
kcp-sys = { git = "https://github.com/EasyTier/kcp-sys", rev = "94964794caaed5d388463137da59b97499619e5f", optional = true }
|
||||
|
||||
prost-reflect = { version = "0.14.5", default-features = false, features = [
|
||||
"derive",
|
||||
@@ -210,8 +236,12 @@ hickory-resolver = "0.25.2"
|
||||
hickory-proto = "0.25.2"
|
||||
|
||||
# for magic dns
|
||||
hickory-client = "0.25.2"
|
||||
hickory-server = { version = "0.25.2", features = ["resolver"] }
|
||||
hickory-client = { version = "0.25.2", optional = true }
|
||||
hickory-server = { version = "0.25.2", features = [
|
||||
"resolver",
|
||||
], optional = true }
|
||||
|
||||
bon = "3.9.1"
|
||||
derive_builder = "0.20.2"
|
||||
humantime-serde = "1.1.1"
|
||||
multimap = "0.10.1"
|
||||
@@ -221,8 +251,7 @@ sha2 = "0.10.8"
|
||||
shellexpand = "3.1.1"
|
||||
|
||||
# for fake tcp
|
||||
flume = "0.12"
|
||||
cfg-if = "1.0"
|
||||
flume = { version = "0.12", optional = true }
|
||||
|
||||
[target.'cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", target_os = "freebsd"))'.dependencies]
|
||||
machine-uid = "0.5.3"
|
||||
@@ -238,7 +267,9 @@ dbus = { version = "0.9.7", features = ["vendored"] }
|
||||
which = "7.0.3"
|
||||
|
||||
[target.'cfg(all(windows, any(target_arch = "x86_64", target_arch = "x86")))'.dependencies]
|
||||
windivert = { git = "https://github.com/EasyTier/windivert-rust.git", rev = "adcc56d1550f7b5377ec2b3429f413ee24a77375", features = ["static"] }
|
||||
windivert = { git = "https://github.com/EasyTier/windivert-rust.git", rev = "adcc56d1550f7b5377ec2b3429f413ee24a77375", features = [
|
||||
"static",
|
||||
] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.52.0", features = [
|
||||
@@ -256,18 +287,20 @@ winreg = "0.52"
|
||||
windows-service = "0.7.0"
|
||||
windows-sys = { version = "0.52", features = [
|
||||
"Win32_NetworkManagement_IpHelper",
|
||||
"Win32_NetworkManagement_Ndis",
|
||||
"Win32_NetworkManagement_Ndis",
|
||||
"Win32_Networking_WinSock",
|
||||
"Win32_Foundation"
|
||||
]}
|
||||
"Win32_Foundation",
|
||||
"Win32_System_Diagnostics",
|
||||
"Win32_System_Diagnostics_Debug",
|
||||
] }
|
||||
winapi = { version = "0.3.9", features = ["impl-default"] }
|
||||
|
||||
[target.'cfg(not(windows))'.dependencies]
|
||||
jemallocator = { package = "tikv-jemallocator", version = "0.6.0", optional = true, features = [
|
||||
"unprefixed_malloc_on_supported_platforms"
|
||||
"unprefixed_malloc_on_supported_platforms",
|
||||
] }
|
||||
jemalloc-ctl = { package = "tikv-jemalloc-ctl", version = "0.6.0", optional = true, features = [
|
||||
"use_std"
|
||||
"use_std",
|
||||
] }
|
||||
|
||||
[target.'cfg(not(target_os = "macos"))'.dependencies]
|
||||
@@ -281,26 +314,24 @@ jemalloc-sys = { package = "tikv-jemalloc-sys", version = "0.6.0", features = [
|
||||
], optional = true }
|
||||
|
||||
[build-dependencies]
|
||||
cfg_aliases = "0.2.1"
|
||||
tonic-build = "0.12"
|
||||
globwalk = "0.8.1"
|
||||
regex = "1"
|
||||
prost-build = "0.13.2"
|
||||
rpc_build = { package = "easytier-rpc-build", version = "0.1.0", features = [
|
||||
prost-build = "0.13.5"
|
||||
prost-wkt-build = "0.6"
|
||||
easytier-rpc-build = { path = "../easytier-rpc-build", features = [
|
||||
"internal-namespace",
|
||||
] }
|
||||
prost-reflect-build = { version = "0.14.0" }
|
||||
thunk-rs = { git = "https://github.com/easytier/thunk.git", default-features = false, features = [
|
||||
"win7",
|
||||
] }
|
||||
|
||||
[target.'cfg(windows)'.build-dependencies]
|
||||
reqwest = { version = "0.12.12", features = ["blocking"] }
|
||||
zip = "4.0.0"
|
||||
|
||||
# enable thunk-rs when compiling for x86_64 or i686 windows
|
||||
[target.x86_64-pc-windows-msvc.build-dependencies]
|
||||
thunk-rs = { git = "https://github.com/easytier/thunk.git", default-features = false, features = ["win7"] }
|
||||
|
||||
[target.i686-pc-windows-msvc.build-dependencies]
|
||||
thunk-rs = { git = "https://github.com/easytier/thunk.git", default-features = false, features = ["win7"] }
|
||||
|
||||
|
||||
[dev-dependencies]
|
||||
serial_test = "3.0.0"
|
||||
@@ -308,6 +339,7 @@ rstest = "0.25.0"
|
||||
futures-util = "0.3.31"
|
||||
maplit = "1.0.2"
|
||||
tempfile = "3.22.0"
|
||||
ctor = "0.8.0"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dev-dependencies]
|
||||
defguard_wireguard_rs = "0.4.2"
|
||||
@@ -315,7 +347,18 @@ tokio-socks = "0.5.2"
|
||||
|
||||
|
||||
[features]
|
||||
default = ["wireguard", "websocket", "smoltcp", "tun", "socks5", "quic"]
|
||||
default = [
|
||||
"wireguard",
|
||||
"websocket",
|
||||
"smoltcp",
|
||||
"tun",
|
||||
"socks5",
|
||||
"kcp",
|
||||
"quic",
|
||||
"faketcp",
|
||||
"magic-dns",
|
||||
"zstd",
|
||||
]
|
||||
full = [
|
||||
"websocket",
|
||||
"wireguard",
|
||||
@@ -324,9 +367,15 @@ full = [
|
||||
"smoltcp",
|
||||
"tun",
|
||||
"socks5",
|
||||
"kcp",
|
||||
"quic",
|
||||
"faketcp",
|
||||
"magic-dns",
|
||||
"zstd",
|
||||
]
|
||||
wireguard = ["dep:boringtun", "dep:ring"]
|
||||
quic = ["dep:quinn", "dep:rustls", "dep:rcgen"]
|
||||
quic = ["dep:quinn", "dep:quinn-plaintext", "dep:rustls", "dep:rcgen"]
|
||||
kcp = ["dep:kcp-sys"]
|
||||
mimalloc = ["dep:mimalloc"]
|
||||
aes-gcm = ["dep:aes-gcm"]
|
||||
openssl-crypto = ["dep:openssl"]
|
||||
@@ -334,12 +383,24 @@ tun = ["dep:tun"]
|
||||
websocket = [
|
||||
"dep:tokio-websockets",
|
||||
"dep:http",
|
||||
"dep:forwarded-header-value",
|
||||
"dep:tokio-rustls",
|
||||
"dep:rustls",
|
||||
"dep:rcgen",
|
||||
]
|
||||
smoltcp = ["dep:smoltcp", "dep:parking_lot"]
|
||||
socks5 = ["dep:smoltcp"]
|
||||
smoltcp = ["dep:smoltcp"]
|
||||
socks5 = ["smoltcp"]
|
||||
jemalloc = ["dep:jemallocator", "dep:jemalloc-sys"]
|
||||
jemalloc-prof = ["jemalloc", "dep:jemalloc-ctl", "jemalloc-ctl/stats", "jemalloc-sys/profiling", "jemalloc-sys/stats"]
|
||||
jemalloc-prof = [
|
||||
"jemalloc",
|
||||
"dep:jemalloc-ctl",
|
||||
"jemalloc-ctl/stats",
|
||||
"jemalloc-sys/profiling",
|
||||
"jemalloc-sys/stats",
|
||||
]
|
||||
tracing = ["tokio/tracing", "dep:console-subscriber"]
|
||||
magic-dns = ["dep:hickory-client", "dep:hickory-server"]
|
||||
faketcp = ["dep:flume"]
|
||||
zstd = ["dep:zstd"]
|
||||
# For Network Extension on macOS
|
||||
macos-ne = []
|
||||
|
||||
+36
-23
@@ -1,5 +1,8 @@
|
||||
use cfg_aliases::cfg_aliases;
|
||||
use prost_wkt_build::{FileDescriptorSet, Message as _};
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::{env, io::Cursor, path::PathBuf};
|
||||
use std::io::Cursor;
|
||||
use std::{env, path::PathBuf};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
struct WindowsBuild {}
|
||||
@@ -83,7 +86,9 @@ impl WindowsBuild {
|
||||
} else {
|
||||
Self::download_protoc()
|
||||
};
|
||||
std::env::set_var("PROTOC", protoc_path);
|
||||
unsafe {
|
||||
std::env::set_var("PROTOC", protoc_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,12 +132,21 @@ fn check_locale() {
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
cfg_aliases! {
|
||||
mobile: {
|
||||
any(
|
||||
target_os = "android",
|
||||
target_os = "ios",
|
||||
all(target_os = "macos", feature = "macos-ne"),
|
||||
target_env = "ohos"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||
let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
|
||||
// enable thunk-rs when target os is windows and arch is x86_64 or i686
|
||||
#[cfg(target_os = "windows")]
|
||||
if !std::env::var("TARGET")
|
||||
.unwrap_or_default()
|
||||
.contains("aarch64")
|
||||
{
|
||||
if target_os == "windows" && (target_arch == "x86" || target_arch == "x86_64") {
|
||||
thunk::thunk();
|
||||
}
|
||||
|
||||
@@ -157,33 +171,28 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("cargo:rerun-if-changed={proto_file}");
|
||||
}
|
||||
|
||||
let out = PathBuf::from(env::var("OUT_DIR").unwrap());
|
||||
let descriptor_file = out.join("descriptors.bin");
|
||||
|
||||
let mut config = prost_build::Config::new();
|
||||
config
|
||||
.type_attribute(".", "#[derive(serde::Serialize,serde::Deserialize)]")
|
||||
.extern_path(".google.protobuf.Any", "::prost_wkt_types::Any")
|
||||
.extern_path(".google.protobuf.Timestamp", "::prost_wkt_types::Timestamp")
|
||||
.extern_path(".google.protobuf.Value", "::prost_wkt_types::Value")
|
||||
.file_descriptor_set_path(&descriptor_file)
|
||||
.protoc_arg("--experimental_allow_proto3_optional")
|
||||
.type_attribute(".acl", "#[derive(serde::Serialize, serde::Deserialize)]")
|
||||
.type_attribute(".common", "#[derive(serde::Serialize, serde::Deserialize)]")
|
||||
.type_attribute(".error", "#[derive(serde::Serialize, serde::Deserialize)]")
|
||||
.type_attribute(".api", "#[derive(serde::Serialize, serde::Deserialize)]")
|
||||
.type_attribute(".web", "#[derive(serde::Serialize, serde::Deserialize)]")
|
||||
.type_attribute(".config", "#[derive(serde::Serialize, serde::Deserialize)]")
|
||||
.type_attribute(
|
||||
"peer_rpc.GetIpListResponse",
|
||||
"#[derive(serde::Serialize, serde::Deserialize)]",
|
||||
)
|
||||
.type_attribute("peer_rpc.DirectConnectedPeerInfo", "#[derive(Hash)]")
|
||||
.type_attribute("peer_rpc.PeerInfoForGlobalMap", "#[derive(Hash)]")
|
||||
.type_attribute("peer_rpc.ForeignNetworkRouteInfoKey", "#[derive(Hash, Eq)]")
|
||||
.type_attribute(
|
||||
"peer_rpc.RouteForeignNetworkSummary.Info",
|
||||
"#[derive(Hash, Eq, serde::Serialize, serde::Deserialize)]",
|
||||
)
|
||||
.type_attribute(
|
||||
"peer_rpc.RouteForeignNetworkSummary",
|
||||
"#[derive(Hash, Eq, serde::Serialize, serde::Deserialize)]",
|
||||
"#[derive(Hash, Eq)]",
|
||||
)
|
||||
.type_attribute("peer_rpc.RouteForeignNetworkSummary", "#[derive(Hash, Eq)]")
|
||||
.type_attribute("common.RpcDescriptor", "#[derive(Hash, Eq)]")
|
||||
.field_attribute(".api.manage.NetworkConfig", "#[serde(default)]")
|
||||
.service_generator(Box::new(rpc_build::ServiceGenerator::new()))
|
||||
.service_generator(Box::new(easytier_rpc_build::ServiceGenerator::default()))
|
||||
.btree_map(["."])
|
||||
.skip_debug([".common.Ipv4Addr", ".common.Ipv6Addr", ".common.UUID"]);
|
||||
|
||||
@@ -193,6 +202,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.file_descriptor_set_bytes("crate::proto::DESCRIPTOR_POOL_BYTES")
|
||||
.compile_protos_with_config(config, &proto_files_reflect, &["src/proto/"])?;
|
||||
|
||||
let descriptor_bytes = std::fs::read(descriptor_file).unwrap();
|
||||
let descriptor = FileDescriptorSet::decode(&descriptor_bytes[..]).unwrap();
|
||||
prost_wkt_build::add_serde(out, descriptor);
|
||||
|
||||
check_locale();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,724 @@
|
||||
# 临时凭据(Credential)系统实现计划
|
||||
|
||||
## Context
|
||||
|
||||
EasyTier 的 secure mode 已实现 Noise XX 握手 + X25519 静态公钥认证。当前节点通过 `network_secret` 双向确认身份。用户需要一种"临时凭据"机制:
|
||||
|
||||
- **管理节点**(任何持有 network_secret 的节点)可为当前网络生成凭据
|
||||
- **新节点**可使用凭据代替 `network_secret` 加入网络
|
||||
- **管理节点**可撤销凭据
|
||||
- **撤销后**,使用该凭据接入的节点被全网踢出
|
||||
|
||||
**核心设计**:凭据 = X25519 密钥对。完全复用现有 Noise `Noise_XX_25519_ChaChaPoly_SHA256` 握手流程,无需修改握手消息格式。通过 OSPF 路由同步传播可信公钥列表,撤销时全网自然断开。
|
||||
|
||||
## 整体架构
|
||||
|
||||
```
|
||||
凭据 = X25519 密钥对
|
||||
- 管理节点生成密钥对,将公钥加入可信列表
|
||||
- 临时节点持有私钥,用作 Noise static key
|
||||
- 全网通过 OSPF 路由同步可信公钥列表
|
||||
|
||||
管理节点 (持有 network_secret):
|
||||
1. generate_credential() → 生成 X25519 密钥对
|
||||
2. 公钥记入 trusted_credential_pubkeys → 随 RoutePeerInfo 通过 OSPF 传播
|
||||
3. revoke → 从 trusted 列表移除 → OSPF 同步 → 全网感知
|
||||
|
||||
临时节点 (持有凭据私钥):
|
||||
1. 使用凭据私钥作为 SecureModeConfig.local_private_key
|
||||
2. Noise 握手完全走现有流程(XX 模式交换 static pubkey)
|
||||
3. 不持有 network_secret,secret_proof 验证会失败,但公钥在可信列表中即可
|
||||
4. RoutePeerInfo.noise_static_pubkey 自然携带凭据公钥
|
||||
|
||||
校验逻辑(每个节点在路由同步时执行):
|
||||
1. 从全网 RoutePeerInfo 中收集管理节点的 trusted_credential_pubkeys(取并集)
|
||||
**安全约束: 仅信任 secure_auth_level=NetworkSecretConfirmed 的节点发布的列表**
|
||||
临时节点(CredentialAuthenticated)发布的 trusted_credential_pubkeys 必须被忽略
|
||||
2. 对每个 peer,如果其 secure_auth_level < NetworkSecretConfirmed:
|
||||
- 检查其 noise_static_pubkey 是否在可信公钥集合中
|
||||
- 不在 → 从路由表移除 → 断开连接
|
||||
```
|
||||
|
||||
## 详细设计
|
||||
|
||||
### Step 1: Protobuf 定义
|
||||
|
||||
**文件: `easytier/src/proto/peer_rpc.proto`**
|
||||
|
||||
在 `RoutePeerInfo` 新增字段(利用已有 `noise_static_pubkey` 字段 #18):
|
||||
```protobuf
|
||||
message TrustedCredentialPubkey {
|
||||
bytes pubkey = 1; // X25519 公钥 (32 bytes)
|
||||
repeated string groups = 2; // 该凭据所属的 ACL group(管理节点声明,无需 proof)
|
||||
bool allow_relay = 3; // 是否允许该临时节点提供 peer relay 能力
|
||||
int64 expiry_unix = 4; // 必选:过期时间(Unix timestamp),过期后自动失效
|
||||
repeated string allowed_proxy_cidrs = 5; // 允许该临时节点声明的 proxy_cidrs 范围
|
||||
}
|
||||
|
||||
message RoutePeerInfo {
|
||||
// ... existing fields 1-18 ...
|
||||
// 管理节点发布的可信凭据公钥列表(含 group 关联)
|
||||
repeated TrustedCredentialPubkey trusted_credential_pubkeys = 19;
|
||||
}
|
||||
```
|
||||
|
||||
临时节点无需新字段——其 `noise_static_pubkey`(字段 18)已经在 OSPF 中传播,只需在校验端判断该公钥是否在可信列表中。
|
||||
|
||||
新增 `SecureAuthLevel` 枚举值:
|
||||
```protobuf
|
||||
enum SecureAuthLevel {
|
||||
None = 0;
|
||||
EncryptedUnauthenticated = 1;
|
||||
SharedNodePubkeyVerified = 2;
|
||||
NetworkSecretConfirmed = 3;
|
||||
CredentialAuthenticated = 4; // 新增:凭据公钥已验证
|
||||
}
|
||||
```
|
||||
|
||||
**文件: `easytier/src/proto/api_instance.proto`**
|
||||
|
||||
新增凭据管理 RPC:
|
||||
```protobuf
|
||||
message GenerateCredentialRequest {
|
||||
repeated string groups = 1; // 可选: 凭据关联的 ACL group
|
||||
bool allow_relay = 2; // 可选: 是否允许该临时节点提供 peer relay
|
||||
repeated string allowed_proxy_cidrs = 3; // 可选: 限制可声明的 proxy_cidrs
|
||||
int64 ttl_seconds = 4; // 必选: 凭据有效期(秒)
|
||||
}
|
||||
message GenerateCredentialResponse {
|
||||
string credential_id = 1; // 公钥的 base64
|
||||
string credential_secret = 2; // 私钥的 base64
|
||||
}
|
||||
message RevokeCredentialRequest { string credential_id = 1; }
|
||||
message RevokeCredentialResponse { bool success = 1; }
|
||||
message ListCredentialsRequest {}
|
||||
message CredentialInfo {
|
||||
string credential_id = 1; // 公钥 base64
|
||||
google.protobuf.Timestamp created_at = 2;
|
||||
}
|
||||
message ListCredentialsResponse { repeated CredentialInfo credentials = 1; }
|
||||
|
||||
service CredentialManageRpc {
|
||||
rpc GenerateCredential(GenerateCredentialRequest) returns (GenerateCredentialResponse);
|
||||
rpc RevokeCredential(RevokeCredentialRequest) returns (RevokeCredentialResponse);
|
||||
rpc ListCredentials(ListCredentialsRequest) returns (ListCredentialsResponse);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: 凭据管理模块
|
||||
|
||||
**新文件: `easytier/src/peers/credential_manager.rs`**
|
||||
|
||||
```rust
|
||||
use x25519_dalek::{StaticSecret, PublicKey};
|
||||
|
||||
pub struct CredentialManager {
|
||||
// 本节点管理的可信凭据
|
||||
credentials: DashMap<String, CredentialEntry>, // credential_id (pubkey base64) -> entry
|
||||
storage_path: Option<PathBuf>, // 可选: 凭据 JSON 文件路径
|
||||
}
|
||||
|
||||
struct CredentialEntry {
|
||||
pubkey_bytes: [u8; 32],
|
||||
groups: Vec<String>, // 关联的 ACL group(管理节点声明)
|
||||
allow_relay: bool, // 是否允许 relay
|
||||
allowed_proxy_cidrs: Vec<String>, // 允许声明的 proxy_cidrs 范围
|
||||
expiry: SystemTime, // 过期时间(必选)
|
||||
created_at: SystemTime,
|
||||
}
|
||||
|
||||
impl CredentialManager {
|
||||
/// 生成新凭据(含 group 关联)
|
||||
/// 返回 (credential_id=公钥base64, credential_secret=私钥base64)
|
||||
pub fn generate_credential(&self, groups: Vec<String>, allow_relay: bool, expiry: SystemTime) -> (String, String) {
|
||||
let private = StaticSecret::random_from_rng(OsRng);
|
||||
let public = PublicKey::from(&private);
|
||||
let id = BASE64_STANDARD.encode(public.as_bytes());
|
||||
let secret = BASE64_STANDARD.encode(private.as_bytes());
|
||||
self.credentials.insert(id.clone(), CredentialEntry {
|
||||
pubkey_bytes: *public.as_bytes(),
|
||||
groups,
|
||||
allow_relay,
|
||||
expiry, // 由调用方传入
|
||||
created_at: SystemTime::now(),
|
||||
});
|
||||
self.save_to_disk(); // 持久化
|
||||
(id, secret)
|
||||
}
|
||||
|
||||
/// 撤销凭据
|
||||
pub fn revoke_credential(&self, credential_id: &str) -> bool;
|
||||
|
||||
/// 获取可信凭据列表(用于 RoutePeerInfo.trusted_credential_pubkeys)
|
||||
pub fn get_trusted_pubkeys(&self) -> Vec<TrustedCredentialPubkey>;
|
||||
|
||||
/// 列出所有凭据
|
||||
pub fn list_credentials(&self) -> Vec<CredentialInfo>;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Noise 握手适配(最小改动)
|
||||
|
||||
**文件: `easytier/src/peers/peer_conn.rs`**
|
||||
|
||||
临时节点的握手流程**完全不需要修改**,因为:
|
||||
- 临时节点配置 `SecureModeConfig { enabled: true, local_private_key: 凭据私钥, local_public_key: 凭据公钥 }`
|
||||
- `get_keypair()` (line 434) 自然返回凭据密钥对
|
||||
- Noise XX 握手正常交换 static pubkey
|
||||
- 唯一区别:`secret_proof_32` 验证会失败(临时节点没有 network_secret)
|
||||
|
||||
需要修改 `do_noise_handshake_as_server()` (line 934):
|
||||
- **当前行为**: `secret_proof` 验证失败 → 返回错误断开连接 (line 1059)
|
||||
- **修改为**: `secret_proof` 验证失败时,不立即断开,而是将 `secure_auth_level` 保持为 `EncryptedUnauthenticated`
|
||||
- 后续由 OSPF 路由同步阶段决定该 peer 是否可信(公钥是否在 trusted 列表中)
|
||||
|
||||
同样修改 `do_noise_handshake_as_client()` (line 680):
|
||||
- 当临时节点连接管理节点时,`secret_proof` 验证失败不应报错
|
||||
- 临时节点可以通过 `pinned_remote_pubkey` 或不验证来处理
|
||||
|
||||
**NoiseHandshakeResult** 新增:
|
||||
```rust
|
||||
// 标记此连接使用了凭据而非 network_secret
|
||||
is_credential_conn: bool,
|
||||
```
|
||||
|
||||
### Step 4: RoutePeerInfo 传播凭据信息
|
||||
|
||||
**文件: `easytier/src/peers/peer_ospf_route.rs`**
|
||||
|
||||
修改 `RoutePeerInfo::new_updated_self()` (line 164):
|
||||
- 管理节点(持有 network_secret): 从 `CredentialManager.get_trusted_pubkeys()` 获取列表,填入 `trusted_credential_pubkeys`
|
||||
- 临时节点: **不填写 `trusted_credential_pubkeys`**(该字段留空),即使收到其他管理节点传播的列表也不转发
|
||||
- 实现方式: 在 `new_updated_self()` 中检查节点身份,临时节点跳过 trusted_credential_pubkeys 填充
|
||||
- 临时节点: 无需额外操作,`noise_static_pubkey` 已自然包含凭据公钥
|
||||
|
||||
### Step 5: 全网校验与自动踢出(核心逻辑)
|
||||
|
||||
**文件: `easytier/src/peers/peer_ospf_route.rs`**
|
||||
|
||||
在 `SyncedRouteInfo` 中新增:
|
||||
```rust
|
||||
// 从全网管理节点汇总的可信凭据公钥集合
|
||||
trusted_credential_pubkeys: DashSet<Vec<u8>>, // pubkey bytes
|
||||
```
|
||||
|
||||
新增校验方法(类似 `verify_and_update_group_trusts` line 743):
|
||||
```rust
|
||||
fn verify_credential_peers(&self, peer_infos: &[RoutePeerInfo]) {
|
||||
// 1. 收集管理节点的 trusted_credential_pubkeys(取并集)
|
||||
// **安全约束: 仅信任 secret_digest 与本网络匹配的节点(即持有 network_secret 的管理节点)**
|
||||
// 临时节点的 trusted_credential_pubkeys 直接忽略,防止恶意临时节点自我授权
|
||||
let mut all_trusted = HashSet::new();
|
||||
for info in peer_infos {
|
||||
if self.is_peer_secret_verified(info.peer_id) {
|
||||
// 该 peer 通过了 network_secret 双向确认,是合法管理节点
|
||||
for tc in &info.trusted_credential_pubkeys {
|
||||
all_trusted.insert(tc.pubkey.clone());
|
||||
}
|
||||
}
|
||||
// else: 该 peer 未通过 network_secret 确认(含临时节点),忽略其 trusted 列表
|
||||
}
|
||||
self.trusted_credential_pubkeys = all_trusted;
|
||||
|
||||
// 2. 检查所有 peer 的凭据状态
|
||||
for info in peer_infos {
|
||||
if !self.is_peer_secret_verified(info.peer_id)
|
||||
&& !info.noise_static_pubkey.is_empty()
|
||||
{
|
||||
if !self.trusted_credential_pubkeys.contains(&info.noise_static_pubkey) {
|
||||
// 该 peer 既不持有 network_secret,其公钥也不在可信列表中
|
||||
// → 标记为不可信,后续从路由表移除
|
||||
self.mark_peer_untrusted(info.peer_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
在 `do_sync_route_info()` (line 2614) 中调用此校验。
|
||||
|
||||
在路由表构建中(`update_route_table_and_cached_local_conn_bitmap()`):
|
||||
- 不可信 peer 不加入路由图
|
||||
- 已连接的不可信 peer 调用 `PeerMap::close_peer()` 断开
|
||||
|
||||
**判断 peer 是否持有 network_secret**: 利用现有 `secret_digest` 字段。管理节点的 `RoutePeerInfo` 中 `secret_digest` 与本节点匹配,说明双方持有相同的 network_secret。
|
||||
|
||||
### Step 6: GlobalCtx / Config 集成
|
||||
|
||||
**文件: `easytier/src/common/global_ctx.rs`**
|
||||
|
||||
在 `GlobalCtx` 新增:
|
||||
```rust
|
||||
credential_manager: Arc<CredentialManager>, // 所有节点都持有,管理节点用于生成/撤销
|
||||
```
|
||||
|
||||
**文件: `easytier/src/common/global_ctx.rs` - `GlobalCtxEvent`**
|
||||
|
||||
新增:
|
||||
```rust
|
||||
CredentialChanged, // 触发 OSPF 立即同步
|
||||
```
|
||||
|
||||
**文件: `easytier/src/common/config.rs`**
|
||||
|
||||
临时节点的配置方式: 直接使用凭据私钥作为 `SecureModeConfig.local_private_key`。
|
||||
可在 `TomlConfigLoader` 中新增便捷字段或 CLI 参数:
|
||||
- `--credential <私钥base64>`: 临时节点使用凭据私钥加入网络
|
||||
- `--credential-file <path>`: 管理节点指定凭据存储 JSON 文件路径
|
||||
|
||||
### Step 7: RPC 服务 + CLI
|
||||
|
||||
**文件: `easytier/src/peers/rpc_service.rs`**
|
||||
|
||||
实现 `CredentialManageRpc`,参考 `PeerManagerRpcService` 模式。
|
||||
|
||||
**CLI** (`easytier-cli`):
|
||||
```
|
||||
easytier-cli credential generate
|
||||
输出: credential_id=<公钥base64> credential_secret=<私钥base64>
|
||||
|
||||
easytier-cli credential revoke <credential_id>
|
||||
easytier-cli credential list
|
||||
```
|
||||
|
||||
**临时节点启动**:
|
||||
```bash
|
||||
# 方式1: 直接传入凭据私钥
|
||||
easytier-core --network-name test \
|
||||
--secure-mode \
|
||||
--credential <私钥base64> \
|
||||
--peers tcp://管理节点:11010
|
||||
|
||||
# 内部实现: 将凭据私钥设为 SecureModeConfig.local_private_key
|
||||
```
|
||||
|
||||
### Step 8: 连接时验证(握手后快速拒绝,必选)
|
||||
|
||||
在 `do_noise_handshake_as_server()` 完成后,**必须**进行快速检查:
|
||||
- 如果对端 `secret_proof` 验证失败(非管理节点),且对端 `noise_static_pubkey` 不在本节点已知的 `trusted_credential_pubkeys` 中
|
||||
- 立即断开连接
|
||||
|
||||
这是**必选的安全措施**(非可选优化)。因为 Step 3 放宽了 secret_proof 失败的处理,如果不做快速拒绝,任何随机节点都能与管理节点建立加密连接并持有,浪费资源。
|
||||
|
||||
```rust
|
||||
// 在 handshake 完成后
|
||||
if !secret_proof_verified {
|
||||
let remote_pubkey = handshake_result.remote_static_pubkey;
|
||||
if !self.global_ctx.credential_manager.is_pubkey_trusted(&remote_pubkey) {
|
||||
return Err(Error::AuthError("unknown credential".to_string()));
|
||||
}
|
||||
// 公钥在 trusted 列表中 → 允许连接,标记为 CredentialAuthenticated
|
||||
handshake_result.secure_auth_level = SecureAuthLevel::CredentialAuthenticated;
|
||||
}
|
||||
```
|
||||
|
||||
## 关键文件清单
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|----------|
|
||||
| `easytier/src/proto/peer_rpc.proto` | `RoutePeerInfo` 加 `trusted_credential_pubkeys`; `SecureAuthLevel` 加 `CredentialAuthenticated` |
|
||||
| `easytier/src/proto/api_instance.proto` | 新增 `CredentialManageRpc` 服务及消息定义 |
|
||||
| `easytier/src/peers/credential_manager.rs` | **新文件** — 凭据管理器(密钥对生成/撤销/列表) |
|
||||
| `easytier/src/peers/mod.rs` | 导出 credential_manager |
|
||||
| `easytier/src/peers/peer_ospf_route.rs` | `new_updated_self()` 填 trusted_pubkeys; 新增 `verify_credential_peers()`; 路由表过滤 |
|
||||
| `easytier/src/peers/peer_conn.rs` | `do_noise_handshake_as_server()` 放宽 secret_proof 失败为非致命; 可选握手阶段快速拒绝 |
|
||||
| `easytier/src/peers/peer_manager.rs` | 集成 CredentialManager; 不可信 peer 断连逻辑 |
|
||||
| `easytier/src/common/global_ctx.rs` | 持有 CredentialManager; 新增 CredentialChanged 事件 |
|
||||
| `easytier/src/common/config.rs` | 新增 `--credential` 参数处理 |
|
||||
| `easytier/src/peers/rpc_service.rs` | 实现 CredentialManageRpc |
|
||||
| `easytier/src/proto/common.rs` | SecureModeConfig 可选: credential 模式识别 |
|
||||
|
||||
## 复用现有机制
|
||||
|
||||
| 现有机制 | 路径 | 复用方式 |
|
||||
|----------|------|----------|
|
||||
| Noise XX 握手 | `peer_conn.rs:680,934` | 临时节点直接使用凭据密钥对走完整 Noise 流程 |
|
||||
| `SecureModeConfig` | `proto/common.rs:367` | 临时节点的凭据私钥直接设为 local_private_key |
|
||||
| `noise_static_pubkey` | `RoutePeerInfo` 字段 18 | 临时节点的凭据公钥已在 OSPF 中传播 |
|
||||
| `verify_and_update_group_trusts()` | `peer_ospf_route.rs:743` | 凭据校验逻辑参考此模式 |
|
||||
| `PeerMap::close_peer()` | `peer_map.rs:317` | 断开不可信 peer |
|
||||
| OSPF 路由同步 | `SyncRouteInfoRequest` | 可信公钥列表随 RoutePeerInfo 自然传播 |
|
||||
| `PeerManagerRpcService` | `rpc_service.rs:24` | RPC 服务实现模式 |
|
||||
| `GlobalCtxEvent` | `global_ctx.rs:32` | 新增事件触发同步 |
|
||||
|
||||
## 验证方案
|
||||
|
||||
1. **单元测试**:
|
||||
- `credential_manager.rs`: 密钥对生成、撤销、列表
|
||||
- `peer_conn.rs`: 凭据节点 Noise 握手成功(无 network_secret)
|
||||
|
||||
2. **集成测试** (参考 `tests/three_node.rs`):
|
||||
- 3 节点: A + B (管理节点, network_secret) + C (临时节点, credential)
|
||||
- A 生成凭据(groups=["guest"])→ C 使用凭据连接 → 验证 C 加入路由表、可达
|
||||
- 验证 C 的 ACL group 为 "guest",配置 group ACL 规则后生效
|
||||
- A 撤销凭据 → 等待 OSPF 同步 (~1-3s) → 验证 C 被 A 和 B 断开
|
||||
- C 尝试重连 → 验证握手阶段被拒
|
||||
|
||||
3. **手动测试**:
|
||||
```bash
|
||||
# A: 管理节点
|
||||
easytier-core -n test -s secret --secure-mode --listeners tcp://0.0.0.0:11010
|
||||
easytier-cli credential generate # → credential_id + credential_secret
|
||||
|
||||
# C: 临时节点
|
||||
easytier-core -n test --secure-mode --credential <私钥base64> --peers tcp://A:11010
|
||||
|
||||
# 验证后撤销
|
||||
easytier-cli credential revoke <credential_id>
|
||||
# C 数秒内被踢出
|
||||
```
|
||||
|
||||
### Step 9: 临时节点 OSPF 路由限制
|
||||
|
||||
**约束**: 临时节点传播的路由信息不可信,需严格限制。
|
||||
|
||||
#### 9a. 管理节点不主动发起到临时节点的 OSPF session
|
||||
|
||||
**核心原则**: OSPF `maintain_sessions()` 构建最小生成树时,只在管理节点之间选择 initiator,不将临时节点纳入 `dst_peer_id_to_initiate`。但管理节点**被动接受**临时节点发起的 session。
|
||||
|
||||
**文件: `easytier/src/peers/peer_ospf_route.rs`**
|
||||
|
||||
修改 `maintain_sessions()` (line 2485):
|
||||
- 在构建 `dst_peer_id_to_initiate` 候选列表时,过滤掉临时节点
|
||||
- 管理节点之间的 MST 不受影响
|
||||
|
||||
```rust
|
||||
// 在 maintain_sessions() 中,构建 initiator 候选时过滤临时节点
|
||||
let peers: Vec<PeerId> = peers.into_iter().filter(|peer_id| {
|
||||
// 只主动发起到管理节点的 session,不主动连临时节点
|
||||
!self.is_credential_peer(*peer_id)
|
||||
}).collect();
|
||||
```
|
||||
|
||||
- **临时节点自身**: 在 `maintain_sessions()` 中只将管理节点作为 initiator 候选,跳过其他临时节点
|
||||
|
||||
```rust
|
||||
// 临时节点侧: 只主动连管理节点
|
||||
if self.is_credential_node() {
|
||||
let peers: Vec<PeerId> = peers.into_iter().filter(|peer_id| {
|
||||
!self.is_credential_peer(*peer_id) // 只连管理节点
|
||||
}).collect();
|
||||
}
|
||||
```
|
||||
|
||||
**session 建立方式**:
|
||||
- **管理节点 → 管理节点**: 正常 MST initiator 选择(不变)
|
||||
- **临时节点 → 管理节点**: 临时节点主动发起 session,管理节点被动接受
|
||||
- **临时节点 → 临时节点**: 不建立(双方都过滤掉对方)
|
||||
- **管理节点 → 临时节点**: 不主动发起(不在 initiator 候选中)
|
||||
|
||||
**路由信息传播**: 临时节点通过其主动发起的 session 调用 `sync_route_info` 推送自身 RoutePeerInfo。管理节点在正常 OSPF sync 中将其代理传播给其他管理节点。管理节点也通过该 session 向临时节点推送完整路由表。
|
||||
|
||||
#### 9b. 管理节点只选择性接收临时节点的路由信息
|
||||
|
||||
**文件: `easytier/src/peers/peer_ospf_route.rs`**
|
||||
|
||||
临时节点通过其主动发起的 session 调用 `sync_route_info`,管理节点在处理时需做过滤:
|
||||
|
||||
- 只接收该临时节点**自己的** `RoutePeerInfo`(`route_info.peer_id == dst_peer_id`),丢弃其声称的其他 peer 的路由信息
|
||||
- 对临时节点自身的 RoutePeerInfo,过滤其 `proxy_cidrs`:只保留在 `TrustedCredentialPubkey.allowed_proxy_cidrs` 范围内的网段,移除超出范围的声明
|
||||
- 临时节点的 `foreign_network_infos` 应忽略
|
||||
- 临时节点的 `conn_info`(连接拓扑)**根据 `allow_relay` 标志决定**(见下方)
|
||||
|
||||
修改 `update_peer_infos()` (line 461):
|
||||
|
||||
```rust
|
||||
fn update_peer_infos(
|
||||
&self, my_peer_id, my_peer_route_id, dst_peer_id,
|
||||
peer_infos, raw_peer_infos,
|
||||
) -> Result<(), Error> {
|
||||
let dst_is_credential_peer = self.is_credential_peer(dst_peer_id);
|
||||
|
||||
for (idx, route_info) in peer_infos.iter().enumerate() {
|
||||
// 临时节点只允许传播自己的路由信息
|
||||
if dst_is_credential_peer && route_info.peer_id != dst_peer_id {
|
||||
tracing::debug!(
|
||||
?dst_peer_id, peer_id=?route_info.peer_id,
|
||||
"ignoring route info from credential peer for other peer"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 过滤临时节点的 proxy_cidrs,只保留凭据允许的范围
|
||||
if dst_is_credential_peer {
|
||||
let allowed = self.get_credential_allowed_proxy_cidrs(dst_peer_id);
|
||||
if let Some(allowed_cidrs) = allowed {
|
||||
route_info.proxy_cidrs.retain(|cidr| {
|
||||
allowed_cidrs.iter().any(|a| cidr_is_subset(cidr, a))
|
||||
});
|
||||
}
|
||||
}
|
||||
// ... existing logic ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
修改 `do_sync_route_info()` (line 2614):
|
||||
|
||||
```rust
|
||||
// 在 do_sync_route_info 中
|
||||
let from_is_credential = self.is_credential_peer(from_peer_id);
|
||||
let credential_allows_relay = from_is_credential
|
||||
&& self.is_credential_relay_allowed(from_peer_id);
|
||||
|
||||
if let Some(peer_infos) = &peer_infos {
|
||||
// update_peer_infos 内部会过滤临时节点的非自身信息
|
||||
service_impl.synced_route_info.update_peer_infos(...);
|
||||
}
|
||||
|
||||
// 临时节点的 conn_info: 仅当 allow_relay=true 时接收
|
||||
if let Some(conn_info) = &conn_info {
|
||||
if !from_is_credential || credential_allows_relay {
|
||||
service_impl.synced_route_info.update_conn_info(conn_info);
|
||||
}
|
||||
}
|
||||
|
||||
// 临时节点的 foreign_network_infos 始终不接收
|
||||
if let Some(foreign_network) = &foreign_network {
|
||||
if !from_is_credential {
|
||||
service_impl.synced_route_info.update_foreign_network(foreign_network);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**conn_info 处理**:
|
||||
- 临时节点的 `conn_info`: 根据凭据的 `allow_relay` 标志决定是否接收
|
||||
- `allow_relay = true`: 管理节点接收并传播该临时节点的 conn_info,使其参与路由图,可作为 relay 转发数据
|
||||
- `allow_relay = false`(默认): 忽略 conn_info,该临时节点不参与中继(仅作为叶子节点存在于路由图中)
|
||||
- 临时节点的 `foreign_network_infos` 始终忽略
|
||||
|
||||
**`is_credential_relay_allowed()` 实现**:
|
||||
```rust
|
||||
fn is_credential_relay_allowed(&self, peer_id: PeerId) -> bool {
|
||||
// 从全网汇总的 trusted_credential_pubkeys 中查找该 peer 的凭据
|
||||
// 检查对应 TrustedCredentialPubkey.allow_relay 标志
|
||||
let peer_info = self.peer_infos.read();
|
||||
if let Some(info) = peer_info.get(&peer_id) {
|
||||
for tc in &self.all_trusted_credentials {
|
||||
if tc.pubkey == info.noise_static_pubkey {
|
||||
return tc.allow_relay;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
```
|
||||
|
||||
**注意**: 即使 `allow_relay=true`,临时节点仍然不能转发握手包(Step 10b 限制不变),因此不会有新节点通过 relay 临时节点接入网络。relay 能力仅用于已建立连接的 peer 之间的数据转发。
|
||||
|
||||
#### 9c. 临时节点的 `RoutePeerInfo` 中的 `trusted_credential_pubkeys` 被忽略
|
||||
|
||||
已在 Step 5 中说明:只信任 `secret_digest` 匹配的管理节点发布的 trusted 列表。
|
||||
|
||||
#### 判断 peer 是否为临时节点的方法
|
||||
|
||||
在 `SyncedRouteInfo` / `PeerRouteServiceImpl` 中新增:
|
||||
```rust
|
||||
fn is_credential_peer(&self, peer_id: PeerId) -> bool {
|
||||
// 方法: 检查该 peer 的 RoutePeerInfo
|
||||
// 1. 如果 peer 的 noise_static_pubkey 在 trusted_credential_pubkeys 中 → 是临时节点
|
||||
// 2. 如果 peer 通过了 network_secret 确认 (secret_digest 匹配) → 是管理节点
|
||||
// 3. 在 peer_conn 握手后,可以记录 secure_auth_level 到连接信息中
|
||||
let peer_info = self.synced_route_info.peer_infos.read();
|
||||
if let Some(info) = peer_info.get(&peer_id) {
|
||||
if !info.noise_static_pubkey.is_empty()
|
||||
&& self.trusted_credential_pubkeys.contains(&info.noise_static_pubkey) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
```
|
||||
|
||||
对于直连 peer,也可以在握手阶段直接记录 `secure_auth_level`,用于快速判断。
|
||||
|
||||
### Step 10: 禁止通过临时节点接入网络
|
||||
|
||||
**约束**: 不得有新节点(无论是否持有 network_secret)通过临时节点的 listener 接入网络。但允许通过管理节点中继后建立 P2P 连接。
|
||||
|
||||
#### 10a. 临时节点天然无法接受新节点接入(无需额外代码)
|
||||
|
||||
临时节点作为 listener 时,新节点的连接会**自然失败**,因为:
|
||||
1. 临时节点没有 `network_secret`,无法验证对端的 `secret_proof` → 无法确认对端是管理节点
|
||||
2. 临时节点不发布 `trusted_credential_pubkeys` → 对端公钥不在可信列表中
|
||||
3. 对端也无法验证临时节点的 `secret_proof`(临时节点没有 network_secret)
|
||||
|
||||
因此 **不需要在 `add_tunnel_as_server()` 中添加显式拦截逻辑**。已有的 Noise 握手 + 凭据校验机制已足够阻止新节点通过临时节点接入。
|
||||
|
||||
**例外**: 已知的管理节点可以连接到临时节点(如 P2P hole punch 场景),因为管理节点的公钥已通过 OSPF 同步被临时节点知晓,握手可以成功。
|
||||
|
||||
#### 10b. 临时节点不转发来自未知 peer 的连接请求
|
||||
|
||||
**文件: `easytier/src/peers/peer_manager.rs`**
|
||||
|
||||
在 packet forwarding 路径 (line 718-766) 中:
|
||||
- 临时节点不应转发 `HandShake` / `NoiseHandshakeMsg*` 类型的包
|
||||
- 这防止新节点通过临时节点的中继接入网络
|
||||
|
||||
```rust
|
||||
// 在 peer_recv 循环的 forward 分支中
|
||||
if to_peer_id != my_peer_id {
|
||||
// 临时节点不转发握手包(阻止新节点通过临时节点接入)
|
||||
if is_credential_node && (
|
||||
hdr.packet_type == PacketType::HandShake as u8
|
||||
|| hdr.packet_type == PacketType::NoiseHandshakeMsg1 as u8
|
||||
|| hdr.packet_type == PacketType::NoiseHandshakeMsg2 as u8
|
||||
|| hdr.packet_type == PacketType::NoiseHandshakeMsg3 as u8
|
||||
) {
|
||||
tracing::debug!("credential node dropping forwarded handshake packet");
|
||||
continue;
|
||||
}
|
||||
// ... existing forward logic ...
|
||||
}
|
||||
```
|
||||
|
||||
#### 10c. P2P 连接通过管理节点中继仍然允许
|
||||
|
||||
P2P hole punch 的流程:
|
||||
1. 两个节点通过管理节点交换打洞信息(RPC)
|
||||
2. 建立直接 P2P tunnel
|
||||
3. 在 P2P tunnel 上握手
|
||||
|
||||
这个流程不受影响,因为:
|
||||
- 打洞信息交换通过管理节点中继(RPC),不经过临时节点
|
||||
- P2P tunnel 建立后的握手是直连,不通过临时节点的 listener
|
||||
- `is_directly_connected=false` 的连接(hole punch 结果)可以被临时节点接受
|
||||
|
||||
**设计思路**: 将凭据映射为 ACL Group,复用现有的 group-based ACL 规则系统。
|
||||
|
||||
现有 ACL 系统已支持基于 group 的规则匹配:
|
||||
- `Rule.source_groups` / `Rule.destination_groups` (acl.proto:72-73)
|
||||
- `PeerGroupInfo` 通过 HMAC proof 验证 peer 所属 group (peer_rpc.rs:8-38)
|
||||
- `verify_and_update_group_trusts()` 在 OSPF 同步时更新 group trust map (peer_ospf_route.rs:743)
|
||||
- `get_peer_groups()` 返回 peer 所属的 group 列表,用于 ACL 匹配 (peer_ospf_route.rs:2287)
|
||||
|
||||
**方案**: 生成凭据时,为每个凭据创建一个隐式 ACL Group。
|
||||
|
||||
1. **凭据生成时**: 管理节点为凭据创建一个关联的 group:
|
||||
- group_name = `"credential:<credential_id>"` 或用户自定义名称
|
||||
- group_secret = 由 credential_secret 派生的密钥
|
||||
- 可选:指定凭据所属的 group_name(批量管理,如 `"guest"`, `"contractor"`)
|
||||
|
||||
2. **临时节点加入时**: 临时节点使用凭据私钥连接。其 group 归属由管理节点在 `TrustedCredentialPubkey.groups` 中声明(无需临时节点自己提供 group proof)。验证节点在 `verify_credential_peers()` 中匹配公钥后,直接将声明的 groups 加入 `group_trust_map`。
|
||||
|
||||
3. **ACL 规则配置**: 管理员可配置基于 group 的 ACL 规则:
|
||||
```toml
|
||||
# 示例配置: 限制 "guest" group 只能访问特定子网
|
||||
[[acl.acl_v1.chains]]
|
||||
name = "inbound"
|
||||
chain_type = "Inbound"
|
||||
default_action = "Allow"
|
||||
|
||||
[[acl.acl_v1.chains.rules]]
|
||||
name = "restrict_guest"
|
||||
source_groups = ["guest"]
|
||||
destination_ips = ["10.0.0.0/24"]
|
||||
action = "Drop"
|
||||
```
|
||||
|
||||
4. **管理节点发布 group 信息**:
|
||||
- 在 `RoutePeerInfo.trusted_credential_pubkeys` 中传播可信公钥时,同时包含关联的 group 信息
|
||||
- 扩展 proto:
|
||||
(使用 Step 1 中定义的 `TrustedCredentialPubkey`,group 归属由管理节点声明,无需 proof 验证)
|
||||
- 替换 `repeated bytes trusted_credential_pubkeys` 为 `repeated TrustedCredentialPubkey trusted_credential_pubkeys`
|
||||
|
||||
5. **校验节点处理**: 在 `verify_credential_peers()` 中:
|
||||
- 验证凭据公钥在可信列表中后
|
||||
- 直接将 `TrustedCredentialPubkey.groups` 中声明的 group 加入 `group_trust_map` / `group_trust_map_cache`(无需验证 group proof,因为管理节点的声明已是可信的)
|
||||
- ACL filter 在处理数据包时自动基于 group 匹配规则
|
||||
|
||||
**API 扩展**:
|
||||
|
||||
生成凭据时可指定 group:
|
||||
```protobuf
|
||||
message GenerateCredentialRequest {
|
||||
repeated string groups = 1; // 可选: 为该凭据关联的 group 名称
|
||||
bool allow_relay = 2; // 可选: 是否允许 relay
|
||||
repeated string allowed_proxy_cidrs = 3; // 可选: 限制可声明的 proxy_cidrs
|
||||
int64 ttl_seconds = 4; // 必选: 凭据有效期(秒)
|
||||
}
|
||||
```
|
||||
|
||||
CLI:
|
||||
```bash
|
||||
# 生成带 group 的凭据,有效期 24 小时
|
||||
easytier-cli credential generate --groups guest,restricted --ttl 86400
|
||||
|
||||
# 生成允许 relay 的凭据,有效期 7 天
|
||||
easytier-cli credential generate --groups relay-node --allow-relay --ttl 604800
|
||||
|
||||
# 最简用法(默认 group 名为 "credential")
|
||||
easytier-cli credential generate --ttl 3600
|
||||
```
|
||||
|
||||
## 安全审查
|
||||
|
||||
### 已覆盖的安全性
|
||||
|
||||
- **端到端加密**: 数据包在源端加密、目的端解密,relay 节点(含 `allow_relay` 的临时节点)无法看到明文
|
||||
- **临时节点自我授权防护**: 只信任 `secret_digest` 匹配的管理节点发布的 `trusted_credential_pubkeys`
|
||||
- **临时节点路由篡改防护**: 只接收临时节点自身的 RoutePeerInfo,忽略其转发的其他路由
|
||||
- **临时节点网络接入防护**: 临时节点天然无法接受新节点接入(无 network_secret、不发布 trusted 列表)
|
||||
|
||||
### 需要关注的安全问题
|
||||
|
||||
**1. Step 8 握手后快速拒绝应为必选(非可选)**
|
||||
|
||||
当前 Step 8 标记为"可选优化",但实际上是**必须的安全措施**。如果不做快速拒绝:
|
||||
- 任何随机节点(无 credential、无 network_secret)都能完成 Noise 握手(因为 Step 3 放宽了 secret_proof 失败)
|
||||
- 在等待 OSPF 同步验证期间,该节点持有一个有效的加密连接,浪费资源
|
||||
- **修改**: Step 8 改为必选。握手完成后立即检查:对端 secret_proof 失败 + 公钥不在本节点已知的 trusted 列表中 → 立即断开
|
||||
|
||||
**2. Group proof 验证机制需要明确**
|
||||
|
||||
当前方案:临时节点在 `RoutePeerInfo.groups` 中携带 `PeerGroupInfo`(HMAC proof),管理节点在 `TrustedCredentialPubkey` 中传播 `group_secret_hash`。
|
||||
|
||||
问题:HMAC 验证需要**原始 secret**,不是 hash。验证节点如何知道 credential 的 group secret?
|
||||
|
||||
**解决方案**: `TrustedCredentialPubkey.group_secret_hash` 改为 `group_secret_digest`,使用与现有 `NetworkIdentity.network_secret_digest` 相同的 digest 算法。验证时:
|
||||
- 管理节点在 `TrustedCredentialPubkey` 中包含 `group_secret_digest`
|
||||
- 临时节点发送的 `PeerGroupInfo` 中包含 `group_proof`(HMAC)
|
||||
- 验证节点无法直接验证 HMAC(没有原始 secret),但可以信任管理节点的声明:如果管理节点在 `TrustedCredentialPubkey.groups` 中列出了某个 group,且临时节点的公钥匹配,就直接信任该 group 归属
|
||||
- 即:**group 归属由管理节点在 `TrustedCredentialPubkey` 中声明,无需临时节点提供 proof**
|
||||
- 这简化了实现,且安全性不降低(管理节点已是可信源)
|
||||
|
||||
**3. 凭据持久化**
|
||||
|
||||
`CredentialManager` 当前设计为内存存储。管理节点重启后所有凭据丢失,导致使用这些凭据的临时节点被踢出。
|
||||
|
||||
**解决方案**:
|
||||
- 管理节点可配置凭据存储的 JSON 文件路径(如 `--credential-file /path/to/credentials.json`)
|
||||
- `CredentialManager` 启动时从该文件加载已有凭据
|
||||
- 生成/撤销凭据时自动写入该文件
|
||||
- 未配置文件路径时,凭据仅存内存(重启丢失)
|
||||
|
||||
**4. 同一凭据多节点复用**
|
||||
|
||||
同一个 credential 私钥可以被多个节点同时使用。它们有不同的 `peer_id` 但相同的 `noise_static_pubkey`。这会导致:
|
||||
- 路由表中多个 RoutePeerInfo 有相同的 `noise_static_pubkey`
|
||||
- 撤销时所有使用该凭据的节点同时被踢出(符合预期)
|
||||
- **这是预期行为**,但应在文档中说明
|
||||
|
||||
**5. 临时节点 proxy_cidrs 限制**
|
||||
|
||||
临时节点可能声明虚假的 `proxy_cidrs`(子网代理),导致流量黑洞。
|
||||
|
||||
**解决方案**(已纳入设计):
|
||||
- 生成凭据时通过 `allowed_proxy_cidrs` 字段限制该凭据可声明的网段范围
|
||||
- 管理节点在 Step 9b 的 `update_peer_infos()` 中过滤:只保留临时节点声明的 proxy_cidrs 中属于 `allowed_proxy_cidrs` 子集的网段
|
||||
- 未配置 `allowed_proxy_cidrs` 时(空列表),临时节点不允许声明任何 proxy_cidrs
|
||||
|
||||
**6. 凭据过期时间(TTL)**
|
||||
|
||||
凭据必须设置过期时间。过期后自动失效,等同于被撤销。
|
||||
- 生成凭据时必须指定 `--ttl` 或 `--expiry`
|
||||
- `verify_credential_peers()` 中检查 `expiry_unix`,过期的凭据从可信列表中移除
|
||||
- 过期检查在每次路由同步时执行,无需额外定时器
|
||||
|
||||
## 优势
|
||||
|
||||
- **最小改动**: Noise 握手消息格式不变,完全复用现有流程
|
||||
- **安全性**: X25519 密钥对提供强身份认证,不弱于 network_secret;端到端加密保护 relay 场景
|
||||
- **自然传播**: 利用 OSPF 已有基础设施,无需新 RPC
|
||||
- **去中心化撤销**: 任何管理节点都可撤销,全网通过路由同步感知
|
||||
- **ACL 复用**: 凭据映射为 ACL Group,完全复用现有 group-based ACL 规则系统,无需新的 ACL 机制
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user