Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
298c3965e2 | ||
|
|
619a5bcf18 | ||
|
|
1acb5e8a86 | ||
|
|
e216416e21 | ||
|
|
7675a6868c | ||
|
|
70e584b37b | ||
|
|
af4b5d9657 | ||
|
|
de0183139d | ||
|
|
26a44e64bd | ||
|
|
453234bf8d | ||
|
|
3e2274dfe5 | ||
|
|
53e092b63d | ||
|
|
b7151880c0 | ||
|
|
60a4403f0e | ||
|
|
da72670154 | ||
|
|
2995cfcb26 | ||
|
|
74c47bce69 | ||
|
|
55d47c5a1c | ||
|
|
cd5b4431dc | ||
|
|
0ccd045e78 | ||
|
|
39a4e60d39 | ||
|
|
6029fd113d | ||
|
|
7513465b61 | ||
|
|
292d54fbc3 | ||
|
|
94d380f9b6 | ||
|
|
061135fb95 | ||
|
|
042254e5ac | ||
|
|
45cce75085 | ||
|
|
c5403b6e31 | ||
|
|
e7b985c233 | ||
|
|
cc80bd2d37 | ||
|
|
90a00215ca | ||
|
|
55d4b874bd | ||
|
|
9993f9c894 | ||
|
|
8ecbfca939 | ||
|
|
4f41468725 | ||
|
|
cb3d1ba14d | ||
|
|
ed6f61ea2a | ||
|
|
d49b7e6651 | ||
|
|
1cc71c8d62 | ||
|
|
92463499ef | ||
|
|
837ef29c15 | ||
|
|
7fbac61e75 | ||
|
|
2e77801adc | ||
|
|
0a73bc3356 | ||
|
|
40e2396741 | ||
|
|
d29c9e3122 | ||
|
|
af42ff7fe6 | ||
|
|
016165ff8e | ||
|
|
b78140dc2d | ||
|
|
1ea38ec715 | ||
|
|
ca72f2b3d4 | ||
|
|
bd79844849 | ||
|
|
bf2458d464 | ||
|
|
1ff7405681 | ||
|
|
6489237233 | ||
|
|
b0db6c9659 | ||
|
|
46d5d24e96 | ||
|
|
3c0532b4c5 | ||
|
|
216cf16310 | ||
|
|
7d481560e3 | ||
|
|
e088d305b1 | ||
|
|
39602c478e | ||
|
|
1dc53ca898 | ||
|
|
12f7eed8a5 | ||
|
|
2aa423062e | ||
|
|
5a31ad89f4 | ||
|
|
ecdb359bbd | ||
|
|
4708ed2b16 |
@@ -0,0 +1,218 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const START_MARKER = "<!-- vnt-downloads:start -->";
|
||||
const END_MARKER = "<!-- vnt-downloads:end -->";
|
||||
|
||||
const TOOL_TARGETS = [
|
||||
["Windows", "x86_64", "x86_64-pc-windows-msvc"],
|
||||
["Windows", "x86", "i686-pc-windows-msvc"],
|
||||
["Windows", "ARM64", "aarch64-pc-windows-msvc"],
|
||||
["Linux", "x86_64", "x86_64-unknown-linux-musl"],
|
||||
["Linux", "ARM64", "aarch64-unknown-linux-musl"],
|
||||
["Linux", "ARMv7 hard-float", "armv7-unknown-linux-musleabihf"],
|
||||
["Linux", "ARMv7 soft-float", "armv7-unknown-linux-musleabi"],
|
||||
["Linux", "ARM hard-float", "arm-unknown-linux-musleabihf"],
|
||||
["Linux", "ARM soft-float", "arm-unknown-linux-musleabi"],
|
||||
["Linux", "MIPS little-endian", "mipsel-unknown-linux-musl"],
|
||||
["Linux", "MIPS big-endian", "mips-unknown-linux-musl"],
|
||||
["macOS", "Apple Silicon", "aarch64-apple-darwin"],
|
||||
["macOS", "Intel", "x86_64-apple-darwin"],
|
||||
["FreeBSD 13.2", "x86_64", "x86_64-unknown-freebsd"],
|
||||
];
|
||||
|
||||
const DOWNLOADS = [
|
||||
{
|
||||
product: "VNT Desktop",
|
||||
platform: "Windows",
|
||||
architecture: "x86_64",
|
||||
format: "EXE 安装包",
|
||||
filename: ({ version }) => `VNT.Desktop_${version}_windows_x64-setup.exe`,
|
||||
},
|
||||
{
|
||||
product: "VNT Desktop",
|
||||
platform: "Windows",
|
||||
architecture: "x86_64",
|
||||
format: "MSI 安装包",
|
||||
filename: ({ version }) => `VNT.Desktop_${version}_windows_x64.msi`,
|
||||
},
|
||||
...TOOL_TARGETS.map(([platform, architecture, target]) => ({
|
||||
product: "VNT 工具包",
|
||||
platform,
|
||||
architecture,
|
||||
format: "ZIP",
|
||||
filename: ({ tag }) => `vnt2-${target}-${tag}.zip`,
|
||||
})),
|
||||
];
|
||||
|
||||
export function buildDownloadSection({ version, tag, releaseUrl, assetNames }) {
|
||||
const rows = DOWNLOADS.flatMap((download) => {
|
||||
const filename = download.filename({ version, tag });
|
||||
if (!assetNames.has(filename)) return [];
|
||||
const url = `${releaseUrl}/${encodeURIComponent(filename)}`;
|
||||
return [
|
||||
`| ${download.product} | ${download.platform} | ${download.architecture} | [${download.format}](${url}) |`,
|
||||
];
|
||||
});
|
||||
|
||||
if (rows.length === 0) return undefined;
|
||||
|
||||
return [
|
||||
START_MARKER,
|
||||
"## 下载",
|
||||
"",
|
||||
"> VNT 工具包包含 `vnt2_web`、`vnt2_cli` 和 `vnt2_ctrl`。",
|
||||
"",
|
||||
"| 产品 | 平台 | 架构 | 下载 |",
|
||||
"|---|---|---|---|",
|
||||
...rows,
|
||||
END_MARKER,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function mergeDownloadSection(notes, section) {
|
||||
const start = notes.indexOf(START_MARKER);
|
||||
if (start === -1) {
|
||||
const existing = notes.trimEnd();
|
||||
return existing.length === 0 ? `${section}\n` : `${existing}\n\n${section}\n`;
|
||||
}
|
||||
|
||||
const end = notes.indexOf(END_MARKER, start);
|
||||
if (end === -1) {
|
||||
throw new Error("release notes contain an incomplete download section");
|
||||
}
|
||||
|
||||
return `${notes.slice(0, start)}${section}${notes.slice(end + END_MARKER.length)}`;
|
||||
}
|
||||
|
||||
function releaseAssetId(url) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (parsed.hostname !== "api.github.com") return undefined;
|
||||
return parsed.pathname.match(/\/releases\/assets\/(\d+)$/)?.[1];
|
||||
}
|
||||
|
||||
export function normalizeUpdaterManifest(manifest, assets) {
|
||||
const downloadUrls = new Map(
|
||||
assets.map((asset) => [String(asset.id), asset.browser_download_url]),
|
||||
);
|
||||
let changed = false;
|
||||
const platforms = Object.fromEntries(
|
||||
Object.entries(manifest.platforms || {}).map(([target, platform]) => {
|
||||
const assetId = releaseAssetId(platform.url);
|
||||
if (!assetId) return [target, platform];
|
||||
|
||||
const downloadUrl = downloadUrls.get(assetId);
|
||||
if (!downloadUrl) {
|
||||
throw new Error(
|
||||
`updater platform ${target} references unknown release asset ${assetId}`,
|
||||
);
|
||||
}
|
||||
changed = true;
|
||||
return [target, { ...platform, url: downloadUrl }];
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
changed,
|
||||
manifest: changed ? { ...manifest, platforms } : manifest,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeReleaseUpdaterManifest({ repository, tag, release }) {
|
||||
const latestAsset = release.assets.find((asset) => asset.name === "latest.json");
|
||||
if (!latestAsset) throw new Error(`release ${tag} does not contain latest.json`);
|
||||
|
||||
const manifest = JSON.parse(
|
||||
execFileSync(
|
||||
"gh",
|
||||
[
|
||||
"api",
|
||||
"-H",
|
||||
"Accept: application/octet-stream",
|
||||
`repos/${repository}/releases/assets/${latestAsset.id}`,
|
||||
],
|
||||
{ encoding: "utf8", env: process.env, windowsHide: true },
|
||||
),
|
||||
);
|
||||
const normalized = normalizeUpdaterManifest(manifest, release.assets);
|
||||
if (!normalized.changed) return;
|
||||
|
||||
const manifestDir = mkdtempSync(join(tmpdir(), "vnt-updater-manifest-"));
|
||||
const manifestPath = join(manifestDir, "latest.json");
|
||||
try {
|
||||
writeFileSync(manifestPath, `${JSON.stringify(normalized.manifest, null, 2)}\n`);
|
||||
execFileSync(
|
||||
"gh",
|
||||
["release", "upload", tag, manifestPath, "--repo", repository, "--clobber"],
|
||||
{ stdio: "inherit", env: process.env, windowsHide: true },
|
||||
);
|
||||
} finally {
|
||||
rmSync(manifestDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function requiredEnv(name) {
|
||||
const value = process.env[name];
|
||||
if (!value) throw new Error(`${name} is required`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const repository = requiredEnv("GITHUB_REPOSITORY");
|
||||
const tag = requiredEnv("GITHUB_REF_NAME");
|
||||
const serverUrl = requiredEnv("GITHUB_SERVER_URL").replace(/\/$/, "");
|
||||
const workspace = process.env.GITHUB_WORKSPACE || process.cwd();
|
||||
const configPath = resolve(workspace, "vnt-desktop/src-tauri/tauri.conf.json");
|
||||
const version = JSON.parse(readFileSync(configPath, "utf8")).version;
|
||||
const release = JSON.parse(
|
||||
execFileSync(
|
||||
"gh",
|
||||
["api", `repos/${repository}/releases/tags/${tag}`],
|
||||
{ encoding: "utf8", env: process.env, windowsHide: true },
|
||||
),
|
||||
);
|
||||
|
||||
normalizeReleaseUpdaterManifest({ repository, tag, release });
|
||||
const assetNames = new Set(release.assets.map((asset) => asset.name));
|
||||
const releaseUrl = `${serverUrl}/${repository}/releases/download/${tag}`;
|
||||
const section = buildDownloadSection({ version, tag, releaseUrl, assetNames });
|
||||
if (!section) throw new Error(`release ${tag} does not contain recognized assets`);
|
||||
|
||||
const notes = release.body || "";
|
||||
const updatedNotes = mergeDownloadSection(notes, section);
|
||||
if (updatedNotes === notes) return;
|
||||
|
||||
const notesFile = join(
|
||||
process.env.RUNNER_TEMP || tmpdir(),
|
||||
`vnt-release-notes-${process.pid}.md`,
|
||||
);
|
||||
try {
|
||||
writeFileSync(notesFile, updatedNotes);
|
||||
execFileSync(
|
||||
"gh",
|
||||
["release", "edit", tag, "--repo", repository, "--notes-file", notesFile],
|
||||
{ stdio: "inherit", env: process.env, windowsHide: true },
|
||||
);
|
||||
} finally {
|
||||
if (existsSync(notesFile)) unlinkSync(notesFile);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildDownloadSection,
|
||||
mergeDownloadSection,
|
||||
normalizeUpdaterManifest,
|
||||
} from "./update-release-downloads.mjs";
|
||||
|
||||
test("builds links for desktop installers and target tool bundles", () => {
|
||||
const section = buildDownloadSection({
|
||||
version: "2.0.0",
|
||||
tag: "v2.0.0",
|
||||
releaseUrl: "https://github.com/vnt-dev/vnt/releases/download/v2.0.0",
|
||||
assetNames: new Set([
|
||||
"VNT.Desktop_2.0.0_windows_x64-setup.exe",
|
||||
"vnt2-x86_64-unknown-linux-musl-v2.0.0.zip",
|
||||
]),
|
||||
});
|
||||
|
||||
assert.match(section, /VNT\.Desktop_2\.0\.0_windows_x64-setup\.exe/);
|
||||
assert.match(section, /vnt2-x86_64-unknown-linux-musl-v2\.0\.0\.zip/);
|
||||
assert.doesNotMatch(section, /VNT\.Desktop_2\.0\.0_windows_x64\.msi/);
|
||||
});
|
||||
|
||||
test("replaces an existing release download section", () => {
|
||||
const oldSection = [
|
||||
"<!-- vnt-downloads:start -->",
|
||||
"old links",
|
||||
"<!-- vnt-downloads:end -->",
|
||||
].join("\n");
|
||||
const merged = mergeDownloadSection(`Release notes\n\n${oldSection}\n`, "new links");
|
||||
|
||||
assert.equal(merged, "Release notes\n\nnew links\n");
|
||||
});
|
||||
|
||||
test("replaces GitHub API updater URLs with public download URLs", () => {
|
||||
const manifest = {
|
||||
version: "2.0.0",
|
||||
platforms: {
|
||||
"windows-x86_64": {
|
||||
signature: "windows-signature",
|
||||
url: "https://api.github.com/repos/vnt-dev/vnt/releases/assets/101",
|
||||
},
|
||||
},
|
||||
};
|
||||
const assets = [
|
||||
{
|
||||
id: 101,
|
||||
browser_download_url:
|
||||
"https://github.com/vnt-dev/vnt/releases/download/v2.0.0/VNT.exe",
|
||||
},
|
||||
];
|
||||
|
||||
const normalized = normalizeUpdaterManifest(manifest, assets);
|
||||
|
||||
assert.equal(normalized.changed, true);
|
||||
assert.equal(
|
||||
normalized.manifest.platforms["windows-x86_64"].url,
|
||||
assets[0].browser_download_url,
|
||||
);
|
||||
assert.equal(
|
||||
normalized.manifest.platforms["windows-x86_64"].signature,
|
||||
"windows-signature",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects updater URLs for unknown release assets", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeUpdaterManifest(
|
||||
{
|
||||
platforms: {
|
||||
"windows-x86_64": {
|
||||
signature: "signature",
|
||||
url: "https://api.github.com/repos/vnt-dev/vnt/releases/assets/999",
|
||||
},
|
||||
},
|
||||
},
|
||||
[],
|
||||
),
|
||||
/unknown release asset 999/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
name: PR Checks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: pr-checks-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
frontend:
|
||||
name: Frontend checks
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.34.5
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build shared Web frontend
|
||||
run: pnpm build:web
|
||||
|
||||
- name: Build Tauri frontend
|
||||
run: pnpm build:desktop-ui
|
||||
|
||||
- name: Test release metadata scripts
|
||||
run: node --test .github/scripts/*.test.mjs
|
||||
|
||||
rust:
|
||||
name: ${{ matrix.name }} Rust checks
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: Windows
|
||||
os: windows-latest
|
||||
- name: Linux
|
||||
os: ubuntu-22.04
|
||||
- name: macOS
|
||||
os: macos-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Install Linux dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
|
||||
- name: Check workspace
|
||||
run: cargo check --workspace --all-targets
|
||||
|
||||
- name: Run Clippy
|
||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --workspace --all-targets
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Rust
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -17,8 +17,36 @@ permissions:
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build-web:
|
||||
name: Build Web frontend
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v5
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.34.5
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Build shared Web frontend
|
||||
run: pnpm build:web
|
||||
- name: Upload Web static files
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: vnt-web-static
|
||||
path: vnt-web/static
|
||||
retention-days: 1
|
||||
|
||||
build:
|
||||
name: Build ${{ matrix.TARGET }}
|
||||
needs: build-web
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -51,16 +79,22 @@ jobs:
|
||||
OS: ubuntu-latest
|
||||
- TARGET: x86_64-unknown-freebsd
|
||||
OS: ubuntu-latest
|
||||
ARTIFACT_NAME: freebsd-13.2-x86_64
|
||||
BSD_VERSION: 13.2
|
||||
ARTIFACT_NAME: freebsd-14.4-x86_64
|
||||
BSD_VERSION: '14.4'
|
||||
runs-on: ${{ matrix.OS }}
|
||||
env:
|
||||
NAME: Vnt
|
||||
TARGET: ${{ matrix.TARGET }}
|
||||
OS: ${{ matrix.OS }}
|
||||
FEATURES: ${{ matrix.FEATURES }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Replace Web static files with current frontend build
|
||||
run: rm -rf vnt-web/static && mkdir -p vnt-web/static
|
||||
- name: Download Web static files
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: vnt-web-static
|
||||
path: vnt-web/static
|
||||
- name: Init submodules
|
||||
run: git submodule update --init --recursive --remote && git submodule status
|
||||
- name: Cargo cache
|
||||
@@ -82,7 +116,7 @@ jobs:
|
||||
- name: List
|
||||
run: find ./
|
||||
- name: Build Vnt X86_64-FreeBSD
|
||||
uses: cross-platform-actions/action@v0.23.0
|
||||
uses: cross-platform-actions/action@v1.3.0
|
||||
if: ${{ endsWith(matrix.TARGET, 'freebsd') }}
|
||||
env:
|
||||
TARGET: ${{ matrix.TARGET }}
|
||||
@@ -260,10 +294,103 @@ jobs:
|
||||
name: Vnt-${{ matrix.TARGET }}
|
||||
path: |
|
||||
./artifacts/*
|
||||
|
||||
build-android-jni:
|
||||
name: Build Android JNI (${{ matrix.abi }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- target: aarch64-linux-android
|
||||
abi: arm64-v8a
|
||||
clang: aarch64-linux-android24-clang
|
||||
cargo_env: AARCH64_LINUX_ANDROID
|
||||
cc_env: CC_aarch64_linux_android
|
||||
ar_env: AR_aarch64_linux_android
|
||||
- target: armv7-linux-androideabi
|
||||
abi: armeabi-v7a
|
||||
clang: armv7a-linux-androideabi24-clang
|
||||
cargo_env: ARMV7_LINUX_ANDROIDEABI
|
||||
cc_env: CC_armv7_linux_androideabi
|
||||
ar_env: AR_armv7_linux_androideabi
|
||||
- target: x86_64-linux-android
|
||||
abi: x86_64
|
||||
clang: x86_64-linux-android24-clang
|
||||
cargo_env: X86_64_LINUX_ANDROID
|
||||
cc_env: CC_x86_64_linux_android
|
||||
ar_env: AR_x86_64_linux_android
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust target
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Set up Android SDK
|
||||
uses: android-actions/setup-android@v4
|
||||
|
||||
- name: Install Android NDK r27d
|
||||
run: sdkmanager "ndk;27.3.13750724"
|
||||
|
||||
- name: Cache Cargo dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: android-jni-${{ matrix.target }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: android-jni-${{ matrix.target }}-
|
||||
|
||||
- name: Build vnt-jni
|
||||
env:
|
||||
TARGET: ${{ matrix.target }}
|
||||
CLANG: ${{ matrix.clang }}
|
||||
CARGO_ENV: ${{ matrix.cargo_env }}
|
||||
CC_ENV: ${{ matrix.cc_env }}
|
||||
AR_ENV: ${{ matrix.ar_env }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NDK_HOME="$ANDROID_SDK_ROOT/ndk/27.3.13750724"
|
||||
toolchain="$NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin"
|
||||
export PATH="$toolchain:$PATH"
|
||||
export "CARGO_TARGET_${CARGO_ENV}_LINKER=$toolchain/$CLANG"
|
||||
export "CARGO_TARGET_${CARGO_ENV}_RUSTFLAGS=-C link-arg=-Wl,-z,max-page-size=16384"
|
||||
export "$CC_ENV=$toolchain/$CLANG"
|
||||
export "$AR_ENV=$toolchain/llvm-ar"
|
||||
cargo build --locked --release -p vnt-jni --target "$TARGET"
|
||||
|
||||
- name: Verify and package native library
|
||||
env:
|
||||
TARGET: ${{ matrix.target }}
|
||||
ABI: ${{ matrix.abi }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NDK_HOME="$ANDROID_SDK_ROOT/ndk/27.3.13750724"
|
||||
library="target/$TARGET/release/libvnt_jni.so"
|
||||
readelf="$NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-readelf"
|
||||
nm="$NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-nm"
|
||||
test -s "$library"
|
||||
"$nm" -D --defined-only "$library" | grep -q Java_com_vnt_VntManager_nativeInit
|
||||
"$readelf" -l "$library" | awk '/ LOAD / { found=1; if ($NF != "0x4000") bad=1 } END { exit (!found || bad) }'
|
||||
mkdir -p artifacts
|
||||
asset="artifacts/vnt-jni-${ABI}-${GITHUB_REF_NAME}.so"
|
||||
cp "$library" "$asset"
|
||||
(cd artifacts && sha256sum "$(basename "$asset")" > "$(basename "$asset").sha256")
|
||||
|
||||
- name: Upload Android JNI artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Vnt-Android-${{ matrix.abi }}
|
||||
path: artifacts/vnt-jni-${{ matrix.abi }}-${{ github.ref_name }}.so*
|
||||
if-no-files-found: error
|
||||
# deploys to github releases on tag
|
||||
deploy:
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
needs: build
|
||||
needs: [build, build-android-jni]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download artifacts
|
||||
@@ -280,3 +407,69 @@ jobs:
|
||||
tag: ${{ github.ref }}
|
||||
overwrite: true
|
||||
file_glob: true
|
||||
- name: Release Android JNI libraries
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: ./artifacts/Vnt-Android-*/*.so*
|
||||
tag: ${{ github.ref }}
|
||||
overwrite: true
|
||||
file_glob: true
|
||||
|
||||
desktop-windows:
|
||||
name: Build and publish VNT Desktop for Windows
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
needs: deploy
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v5
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.34.5
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Verify tag matches desktop version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$config = Get-Content vnt-desktop/src-tauri/tauri.conf.json | ConvertFrom-Json
|
||||
$tagVersion = "${{ github.ref_name }}" -replace '^v', ''
|
||||
if ($tagVersion -ne $config.version) {
|
||||
throw "Tag ${{ github.ref_name }} does not match desktop version $($config.version)"
|
||||
}
|
||||
- name: Build and publish Windows desktop installers
|
||||
uses: tauri-apps/tauri-action@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
with:
|
||||
projectPath: vnt-desktop
|
||||
tagName: ${{ github.ref_name }}
|
||||
releaseName: VNT ${{ github.ref_name }}
|
||||
releaseDraft: false
|
||||
prerelease: false
|
||||
releaseAssetNamePattern: 'VNT.Desktop_[version]_windows_[arch][setup][ext]'
|
||||
args: --bundles nsis,msi
|
||||
|
||||
release-notes:
|
||||
name: Add download links to release notes
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
needs: desktop-windows
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v5
|
||||
- name: Update updater metadata and release notes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: node .github/scripts/update-release-downloads.mjs
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
.idea
|
||||
/target
|
||||
./wintun.dll
|
||||
vnt_config
|
||||
vnt_current_config.txt
|
||||
logs/*
|
||||
/.tools/
|
||||
node_modules
|
||||
vnt-web/ui/dist
|
||||
vnt-desktop/dist
|
||||
vnt-desktop/src-tauri/gen
|
||||
vnt-desktop/src-tauri/icons/android
|
||||
vnt-desktop/src-tauri/icons/ios
|
||||
|
||||
# 前端构建产物,由 vnt-web/build.rs 自动构建
|
||||
vnt-web/static
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/node_modules/
|
||||
/vnt-desktop/node_modules/
|
||||
/vnt-web/ui/node_modules/
|
||||
/vnt-desktop/dist/
|
||||
/vnt-web/static/
|
||||
@@ -2,6 +2,12 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.7.8"
|
||||
@@ -22,6 +28,21 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alloc-no-stdlib"
|
||||
version = "2.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
|
||||
|
||||
[[package]]
|
||||
name = "alloc-stdlib"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
@@ -87,6 +108,15 @@ version = "1.0.101"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.8.1"
|
||||
@@ -120,7 +150,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.114",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
@@ -132,7 +162,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -169,6 +199,38 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-executor"
|
||||
version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
|
||||
dependencies = [
|
||||
"async-task",
|
||||
"concurrent-queue",
|
||||
"fastrand",
|
||||
"futures-lite",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-io"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"cfg-if",
|
||||
"concurrent-queue",
|
||||
"futures-io",
|
||||
"futures-lite",
|
||||
"parking",
|
||||
"polling",
|
||||
"rustix",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-lock"
|
||||
version = "3.4.2"
|
||||
@@ -180,6 +242,35 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-process"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-signal",
|
||||
"async-task",
|
||||
"blocking",
|
||||
"cfg-if",
|
||||
"event-listener",
|
||||
"futures-lite",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-recursion"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-scoped"
|
||||
version = "0.9.0"
|
||||
@@ -191,6 +282,24 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-signal"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
|
||||
dependencies = [
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"atomic-waker",
|
||||
"cfg-if",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"rustix",
|
||||
"signal-hook-registry",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-task"
|
||||
version = "4.7.1"
|
||||
@@ -205,7 +314,30 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atk"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b"
|
||||
dependencies = [
|
||||
"atk-sys",
|
||||
"glib",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atk-sys"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086"
|
||||
dependencies = [
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -272,12 +404,33 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.21.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
||||
dependencies = [
|
||||
"bit-vec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
@@ -289,6 +442,9 @@ name = "bitflags"
|
||||
version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
@@ -299,6 +455,24 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blocking"
|
||||
version = "1.6.2"
|
||||
@@ -312,12 +486,48 @@ dependencies = [
|
||||
"piper",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "8.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
"brotli-decompressor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli-decompressor"
|
||||
version = "5.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bs58"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
|
||||
dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.19.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
@@ -329,6 +539,9 @@ name = "bytes"
|
||||
version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "c2rust-bitfields"
|
||||
@@ -347,7 +560,74 @@ checksum = "3b457277798202ccd365b9c112ebee08ddd57f1033916c8b8ea52f222e5b715d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cairo-rs"
|
||||
version = "0.18.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"cairo-sys-rs",
|
||||
"glib",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cairo-sys-rs"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51"
|
||||
dependencies = [
|
||||
"glib-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "camino"
|
||||
version = "1.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cargo-platform"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cargo_metadata"
|
||||
version = "0.19.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba"
|
||||
dependencies = [
|
||||
"camino",
|
||||
"cargo-platform",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cargo_toml"
|
||||
version = "0.22.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"toml 0.9.11+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -360,6 +640,33 @@ dependencies = [
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cesu8"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
||||
|
||||
[[package]]
|
||||
name = "cfb"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"fnv",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-expr"
|
||||
version = "0.15.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02"
|
||||
dependencies = [
|
||||
"smallvec",
|
||||
"target-lexicon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
@@ -372,6 +679,17 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.43"
|
||||
@@ -380,6 +698,7 @@ checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
@@ -411,10 +730,10 @@ version = "4.5.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -443,7 +762,7 @@ checksum = "9f7c1b60bae2c3d45228dfb096046aa51ef6c300de70b658d7a13fcb0c4f832e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -452,6 +771,16 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
@@ -474,6 +803,22 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
|
||||
dependencies = [
|
||||
"time",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
@@ -500,6 +845,30 @@ version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "core-graphics"
|
||||
version = "0.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-graphics-types"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"core-foundation 0.10.1",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -509,6 +878,33 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-queue"
|
||||
version = "0.3.12"
|
||||
@@ -534,6 +930,38 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssparser"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2"
|
||||
dependencies = [
|
||||
"cssparser-macros",
|
||||
"dtoa-short",
|
||||
"itoa",
|
||||
"phf",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssparser-macros"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "csv"
|
||||
version = "1.4.0"
|
||||
@@ -555,6 +983,56 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctor"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98"
|
||||
dependencies = [
|
||||
"ctor-proc-macro",
|
||||
"dtor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctor-proc-macro"
|
||||
version = "0.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
|
||||
dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "6.1.0"
|
||||
@@ -575,6 +1053,48 @@ version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||
|
||||
[[package]]
|
||||
name = "dbus"
|
||||
version = "0.9.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"libdbus-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"defmt-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt-macros"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
|
||||
dependencies = [
|
||||
"defmt-parser",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt-parser"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
|
||||
dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der-parser"
|
||||
version = "10.0.0"
|
||||
@@ -596,6 +1116,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587"
|
||||
dependencies = [
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -616,7 +1148,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"syn",
|
||||
"syn 2.0.114",
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
@@ -642,8 +1174,52 @@ version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"block-buffer 0.10.4",
|
||||
"crypto-common 0.1.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.1",
|
||||
"const-oid",
|
||||
"crypto-common 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dispatch2"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -654,7 +1230,30 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dlopen2"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4"
|
||||
dependencies = [
|
||||
"dlopen2_derive",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dlopen2_derive"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -667,6 +1266,66 @@ dependencies = [
|
||||
"quick-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dom_query"
|
||||
version = "0.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89"
|
||||
dependencies = [
|
||||
"bit-set",
|
||||
"cssparser",
|
||||
"foldhash 0.2.0",
|
||||
"html5ever",
|
||||
"precomputed-hash",
|
||||
"selectors",
|
||||
"tendril",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dpi"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dtoa"
|
||||
version = "1.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
|
||||
|
||||
[[package]]
|
||||
name = "dtoa-short"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
|
||||
dependencies = [
|
||||
"dtoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dtor"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4"
|
||||
dependencies = [
|
||||
"dtor-proc-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dtor-proc-macro"
|
||||
version = "0.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5"
|
||||
|
||||
[[package]]
|
||||
name = "dunce"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "dyn-clone"
|
||||
version = "1.0.20"
|
||||
@@ -679,6 +1338,26 @@ version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"memchr",
|
||||
"rustc_version",
|
||||
"toml 1.0.6+spec-1.1.0",
|
||||
"vswhom",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embed_plist"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||
|
||||
[[package]]
|
||||
name = "encode_unicode"
|
||||
version = "1.0.0"
|
||||
@@ -694,12 +1373,50 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
|
||||
dependencies = [
|
||||
"enumflags2_derive",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2_derive"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
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"
|
||||
@@ -740,6 +1457,35 @@ dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
|
||||
dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "field-offset"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
@@ -753,15 +1499,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
||||
|
||||
[[package]]
|
||||
name = "flume"
|
||||
version = "0.11.1"
|
||||
name = "flate2"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
|
||||
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"nanorand",
|
||||
"spin",
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -788,6 +1532,39 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||
dependencies = [
|
||||
"foreign-types-macros",
|
||||
"foreign-types-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-macros"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
@@ -851,7 +1628,10 @@ version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
@@ -863,7 +1643,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -896,6 +1676,105 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gdk"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691"
|
||||
dependencies = [
|
||||
"cairo-rs",
|
||||
"gdk-pixbuf",
|
||||
"gdk-sys",
|
||||
"gio",
|
||||
"glib",
|
||||
"libc",
|
||||
"pango",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gdk-pixbuf"
|
||||
version = "0.18.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec"
|
||||
dependencies = [
|
||||
"gdk-pixbuf-sys",
|
||||
"gio",
|
||||
"glib",
|
||||
"libc",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gdk-pixbuf-sys"
|
||||
version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7"
|
||||
dependencies = [
|
||||
"gio-sys",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gdk-sys"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7"
|
||||
dependencies = [
|
||||
"cairo-sys-rs",
|
||||
"gdk-pixbuf-sys",
|
||||
"gio-sys",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"pango-sys",
|
||||
"pkg-config",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gdkwayland-sys"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69"
|
||||
dependencies = [
|
||||
"gdk-sys",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gdkx11"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe"
|
||||
dependencies = [
|
||||
"gdk",
|
||||
"gdkx11-sys",
|
||||
"gio",
|
||||
"glib",
|
||||
"libc",
|
||||
"x11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gdkx11-sys"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d"
|
||||
dependencies = [
|
||||
"gdk-sys",
|
||||
"glib-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
"x11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generator"
|
||||
version = "0.7.5"
|
||||
@@ -952,17 +1831,171 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"r-efi 5.3.0",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gio"
|
||||
version = "0.18.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-util",
|
||||
"gio-sys",
|
||||
"glib",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gio-sys"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2"
|
||||
dependencies = [
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glib"
|
||||
version = "0.18.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
"gio-sys",
|
||||
"glib-macros",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"memchr",
|
||||
"once_cell",
|
||||
"smallvec",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glib-macros"
|
||||
version = "0.18.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc"
|
||||
dependencies = [
|
||||
"heck 0.4.1",
|
||||
"proc-macro-crate 2.0.2",
|
||||
"proc-macro-error",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glib-sys"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glob"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||
|
||||
[[package]]
|
||||
name = "gobject-sys"
|
||||
version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44"
|
||||
dependencies = [
|
||||
"glib-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gtk"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a"
|
||||
dependencies = [
|
||||
"atk",
|
||||
"cairo-rs",
|
||||
"field-offset",
|
||||
"futures-channel",
|
||||
"gdk",
|
||||
"gdk-pixbuf",
|
||||
"gio",
|
||||
"glib",
|
||||
"gtk-sys",
|
||||
"gtk3-macros",
|
||||
"libc",
|
||||
"pango",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gtk-sys"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414"
|
||||
dependencies = [
|
||||
"atk-sys",
|
||||
"cairo-sys-rs",
|
||||
"gdk-pixbuf-sys",
|
||||
"gdk-sys",
|
||||
"gio-sys",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"pango-sys",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gtk3-macros"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d"
|
||||
dependencies = [
|
||||
"proc-macro-crate 1.3.1",
|
||||
"proc-macro-error",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
@@ -984,7 +2017,7 @@ version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
"foldhash 0.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -993,12 +2026,24 @@ version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
@@ -1016,6 +2061,16 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.38.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"markup5ever",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
@@ -1049,12 +2104,6 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-range-header"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
@@ -1073,6 +2122,15 @@ version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.8.1"
|
||||
@@ -1092,6 +2150,22 @@ dependencies = [
|
||||
"pin-utils",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1100,13 +2174,21 @@ version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"ipnet",
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2 0.6.2",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1133,6 +2215,137 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ico"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"png 0.17.16",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"potential_utf",
|
||||
"utf8_iter",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_normalizer_data",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"smallvec",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer_data"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_collections",
|
||||
"icu_locale_core",
|
||||
"icu_properties_data",
|
||||
"icu_provider",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties_data"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider"
|
||||
version = "2.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
|
||||
dependencies = [
|
||||
"idna_adapter",
|
||||
"smallvec",
|
||||
"utf8_iter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna_adapter"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
|
||||
dependencies = [
|
||||
"icu_normalizer",
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"hashbrown 0.12.3",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.13.0"
|
||||
@@ -1141,6 +2354,17 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.16.1",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "infer"
|
||||
version = "0.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1161,6 +2385,25 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-docker"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-wsl"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
|
||||
dependencies = [
|
||||
"is-docker",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
@@ -1182,6 +2425,153 @@ version = "1.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||
|
||||
[[package]]
|
||||
name = "javascriptcore-rs"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"glib",
|
||||
"javascriptcore-rs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "javascriptcore-rs-sys"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124"
|
||||
dependencies = [
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff"
|
||||
version = "0.2.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
|
||||
dependencies = [
|
||||
"defmt",
|
||||
"jiff-core",
|
||||
"jiff-static",
|
||||
"jiff-tzdb-platform",
|
||||
"log",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"serde_core",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-core"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
|
||||
dependencies = [
|
||||
"defmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-static"
|
||||
version = "0.2.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204"
|
||||
dependencies = [
|
||||
"jiff-core",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-tzdb"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e"
|
||||
|
||||
[[package]]
|
||||
name = "jiff-tzdb-platform"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
|
||||
dependencies = [
|
||||
"jiff-tzdb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
|
||||
dependencies = [
|
||||
"cesu8",
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-sys 0.3.0",
|
||||
"log",
|
||||
"thiserror 1.0.69",
|
||||
"walkdir",
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-macros",
|
||||
"jni-sys 0.4.1",
|
||||
"log",
|
||||
"simd_cesu8",
|
||||
"thiserror 2.0.18",
|
||||
"walkdir",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-macros"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"simd_cesu8",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
|
||||
dependencies = [
|
||||
"jni-sys-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys-macros"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.85"
|
||||
@@ -1192,18 +2582,94 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "json-patch"
|
||||
version = "3.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08"
|
||||
dependencies = [
|
||||
"jsonptr",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonptr"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyboard-types"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"serde",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "libappindicator"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a"
|
||||
dependencies = [
|
||||
"glib",
|
||||
"gtk",
|
||||
"gtk-sys",
|
||||
"libappindicator-sys",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libappindicator-sys"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf"
|
||||
dependencies = [
|
||||
"gtk-sys",
|
||||
"libloading 0.7.4",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.180"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
|
||||
|
||||
[[package]]
|
||||
name = "libdbus-sys"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043"
|
||||
dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.9.0"
|
||||
@@ -1220,12 +2686,27 @@ version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
@@ -1267,7 +2748,7 @@ dependencies = [
|
||||
"log-mdc",
|
||||
"mock_instant",
|
||||
"parking_lot 0.12.5",
|
||||
"rand",
|
||||
"rand 0.9.2",
|
||||
"serde",
|
||||
"serde-value",
|
||||
"serde_json",
|
||||
@@ -1309,24 +2790,35 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "lz4_flex"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab6473172471198271ff72e9379150e9dfd70d8e533e0752a27e515b48dd375e"
|
||||
checksum = "ecbdfe44b1bd960b68170b417450a628c43f7cf56bb3c5317e61cb230ee7f226"
|
||||
dependencies = [
|
||||
"twox-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "machine-uid"
|
||||
version = "0.5.4"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d7217d573cdb141d6da43113b098172e057d39915d79c4bdedbc3aacd46bd96"
|
||||
checksum = "cfe0d6336d341b10ae80e099b8f3c59f34bf8aef66ef25e83aa98bf2955c0ae7"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-registry",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.38.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862"
|
||||
dependencies = [
|
||||
"log",
|
||||
"tendril",
|
||||
"web_atoms",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
@@ -1379,6 +2871,22 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.1.1"
|
||||
@@ -1396,6 +2904,27 @@ version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dce6dd36094cac388f119d2e9dc82dc730ef91c32a6222170d630e5414b956e6"
|
||||
|
||||
[[package]]
|
||||
name = "muda"
|
||||
version = "0.19.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dpi",
|
||||
"gtk",
|
||||
"keyboard-types",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"once_cell",
|
||||
"png 0.18.1",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multimap"
|
||||
version = "0.10.1"
|
||||
@@ -1403,12 +2932,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
|
||||
|
||||
[[package]]
|
||||
name = "nanorand"
|
||||
version = "0.7.0"
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3"
|
||||
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"bitflags 2.10.0",
|
||||
"jni-sys 0.3.0",
|
||||
"log",
|
||||
"ndk-sys",
|
||||
"num_enum",
|
||||
"raw-window-handle",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk-sys"
|
||||
version = "0.6.0+11769913"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
|
||||
dependencies = [
|
||||
"jni-sys 0.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1502,6 +3046,12 @@ dependencies = [
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "new_debug_unreachable"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.30.1"
|
||||
@@ -1603,10 +3153,10 @@ version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro-crate 3.4.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1618,6 +3168,214 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
|
||||
dependencies = [
|
||||
"objc2-encode",
|
||||
"objc2-exception-helper",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-app-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"block2",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-cloud-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-data"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-foundation"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"dispatch2",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-graphics"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"dispatch2",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-io-surface",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-image"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-location"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-text"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-encode"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
|
||||
|
||||
[[package]]
|
||||
name = "objc2-exception-helper"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-foundation"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-io-surface"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-osa-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-quartz-core"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-ui-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"block2",
|
||||
"objc2",
|
||||
"objc2-cloud-kit",
|
||||
"objc2-core-data",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-core-image",
|
||||
"objc2-core-location",
|
||||
"objc2-core-text",
|
||||
"objc2-foundation",
|
||||
"objc2-quartz-core",
|
||||
"objc2-user-notifications",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-user-notifications"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-web-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"block2",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oid-registry"
|
||||
version = "0.8.1"
|
||||
@@ -1639,12 +3397,29 @@ version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9cfef937e9c486488c7e3d949ae31c0f1d06bdacd75b99c086cb35356e30408"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"is-wsl",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "ordered-float"
|
||||
version = "2.10.1"
|
||||
@@ -1654,6 +3429,55 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered-stream"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "osakit"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"objc2-osa-kit",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
version = "0.18.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4"
|
||||
dependencies = [
|
||||
"gio",
|
||||
"glib",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"pango-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pango-sys"
|
||||
version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5"
|
||||
dependencies = [
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
@@ -1720,7 +3544,7 @@ version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
@@ -1738,7 +3562,60 @@ checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
|
||||
dependencies = [
|
||||
"fixedbitset",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
||||
dependencies = [
|
||||
"phf_macros",
|
||||
"phf_shared",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_codegen"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_macros"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1758,7 +3635,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1784,6 +3661,25 @@ dependencies = [
|
||||
"futures-io",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||
|
||||
[[package]]
|
||||
name = "plist"
|
||||
version = "1.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap 2.13.0",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pnet_base"
|
||||
version = "0.35.0"
|
||||
@@ -1802,7 +3698,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"syn",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1826,6 +3722,70 @@ dependencies = [
|
||||
"pnet_macros_support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.17.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polling"
|
||||
version = "3.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"concurrent-queue",
|
||||
"hermit-abi",
|
||||
"pin-project-lite",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic-util"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
|
||||
dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
|
||||
dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
@@ -1841,6 +3801,12 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "precomputed-hash"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
@@ -1848,7 +3814,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "1.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"toml_edit 0.19.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24"
|
||||
dependencies = [
|
||||
"toml_datetime 0.6.3",
|
||||
"toml_edit 0.20.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1857,7 +3843,31 @@ version = "3.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983"
|
||||
dependencies = [
|
||||
"toml_edit",
|
||||
"toml_edit 0.23.10+spec-1.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
|
||||
dependencies = [
|
||||
"proc-macro-error-attr",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error-attr"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1885,7 +3895,7 @@ version = "0.14.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"heck 0.5.0",
|
||||
"itertools",
|
||||
"log",
|
||||
"multimap",
|
||||
@@ -1894,7 +3904,7 @@ dependencies = [
|
||||
"prost",
|
||||
"prost-types",
|
||||
"regex",
|
||||
"syn",
|
||||
"syn 2.0.114",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
@@ -1908,7 +3918,7 @@ dependencies = [
|
||||
"itertools",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1920,12 +3930,85 @@ dependencies = [
|
||||
"prost",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "protoc-bin-vendored"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1c381df33c98266b5f08186583660090a4ffa0889e76c7e9a5e175f645a67fa"
|
||||
dependencies = [
|
||||
"protoc-bin-vendored-linux-aarch_64",
|
||||
"protoc-bin-vendored-linux-ppcle_64",
|
||||
"protoc-bin-vendored-linux-s390_64",
|
||||
"protoc-bin-vendored-linux-x86_32",
|
||||
"protoc-bin-vendored-linux-x86_64",
|
||||
"protoc-bin-vendored-macos-aarch_64",
|
||||
"protoc-bin-vendored-macos-x86_64",
|
||||
"protoc-bin-vendored-win32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "protoc-bin-vendored-linux-aarch_64"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c350df4d49b5b9e3ca79f7e646fde2377b199e13cfa87320308397e1f37e1a4c"
|
||||
|
||||
[[package]]
|
||||
name = "protoc-bin-vendored-linux-ppcle_64"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a55a63e6c7244f19b5c6393f025017eb5d793fd5467823a099740a7a4222440c"
|
||||
|
||||
[[package]]
|
||||
name = "protoc-bin-vendored-linux-s390_64"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1dba5565db4288e935d5330a07c264a4ee8e4a5b4a4e6f4e83fad824cc32f3b0"
|
||||
|
||||
[[package]]
|
||||
name = "protoc-bin-vendored-linux-x86_32"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8854774b24ee28b7868cd71dccaae8e02a2365e67a4a87a6cd11ee6cdbdf9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "protoc-bin-vendored-linux-x86_64"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b38b07546580df720fa464ce124c4b03630a6fb83e05c336fea2a241df7e5d78"
|
||||
|
||||
[[package]]
|
||||
name = "protoc-bin-vendored-macos-aarch_64"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89278a9926ce312e51f1d999fee8825d324d603213344a9a706daa009f1d8092"
|
||||
|
||||
[[package]]
|
||||
name = "protoc-bin-vendored-macos-x86_64"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81745feda7ccfb9471d7a4de888f0652e806d5795b61480605d4943176299756"
|
||||
|
||||
[[package]]
|
||||
name = "protoc-bin-vendored-win32"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.41.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
@@ -1955,7 +4038,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand",
|
||||
"rand 0.9.2",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
@@ -1996,6 +4079,12 @@ version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "r-ex"
|
||||
version = "1.0.1"
|
||||
@@ -2009,7 +4098,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2019,7 +4119,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2031,6 +4131,18 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
||||
|
||||
[[package]]
|
||||
name = "rcgen"
|
||||
version = "0.14.7"
|
||||
@@ -2063,6 +4175,17 @@ dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"libredox",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reed-solomon-erasure"
|
||||
version = "6.0.0"
|
||||
@@ -2076,6 +4199,26 @@ dependencies = [
|
||||
"spin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ref-cast"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3"
|
||||
dependencies = [
|
||||
"ref-cast-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ref-cast-impl"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
@@ -2105,6 +4248,45 @@ version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http 0.6.11",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -2125,7 +4307,7 @@ version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "319bb478ff9aae1dc7a544fa599e9eb47e2521621dc3921fba6abcaaa312092c"
|
||||
dependencies = [
|
||||
"flume 0.12.0",
|
||||
"flume",
|
||||
"libc",
|
||||
"netlink-packet-core 0.8.1",
|
||||
"netlink-packet-route 0.28.0",
|
||||
@@ -2153,7 +4335,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rust-embed-utils",
|
||||
"syn",
|
||||
"syn 2.0.114",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -2163,7 +4345,7 @@ version = "8.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1"
|
||||
dependencies = [
|
||||
"sha2",
|
||||
"sha2 0.10.9",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -2186,7 +4368,7 @@ dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"parking_lot 0.12.5",
|
||||
"rand",
|
||||
"rand 0.9.2",
|
||||
"serde",
|
||||
"socket2 0.5.10",
|
||||
"stun-format",
|
||||
@@ -2268,6 +4450,33 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"jni 0.22.4",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier-android"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.9"
|
||||
@@ -2309,6 +4518,57 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "0.8.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615"
|
||||
dependencies = [
|
||||
"dyn-clone",
|
||||
"indexmap 1.9.3",
|
||||
"schemars_derive",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"url",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
|
||||
dependencies = [
|
||||
"dyn-clone",
|
||||
"ref-cast",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
|
||||
dependencies = [
|
||||
"dyn-clone",
|
||||
"ref-cast",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars_derive"
|
||||
version = "0.8.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde_derive_internals",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scoped-tls"
|
||||
version = "1.0.1"
|
||||
@@ -2344,11 +4604,34 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.36.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"cssparser",
|
||||
"derive_more",
|
||||
"log",
|
||||
"new_debug_unreachable",
|
||||
"phf",
|
||||
"phf_codegen",
|
||||
"precomputed-hash",
|
||||
"rustc-hash",
|
||||
"servo_arc",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
@@ -2360,6 +4643,18 @@ dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde-untagged"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058"
|
||||
dependencies = [
|
||||
"erased-serde",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"typeid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde-value"
|
||||
version = "0.7.0"
|
||||
@@ -2387,7 +4682,18 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive_internals"
|
||||
version = "0.29.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2414,6 +4720,26 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.0.4"
|
||||
@@ -2435,13 +4761,46 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bs58",
|
||||
"chrono",
|
||||
"hex",
|
||||
"indexmap 1.9.3",
|
||||
"indexmap 2.13.0",
|
||||
"jiff",
|
||||
"schemars 0.9.0",
|
||||
"schemars 1.2.2",
|
||||
"serde_core",
|
||||
"serde_json",
|
||||
"serde_with_macros",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with_macros"
|
||||
version = "3.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml"
|
||||
version = "0.9.34+deprecated"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
@@ -2449,14 +4808,45 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
name = "serialize-to-javascript"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serialize-to-javascript-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serialize-to-javascript-impl"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "servo_arc"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
"cpufeatures 0.3.0",
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2466,8 +4856,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2495,6 +4896,34 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "simd_cesu8"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
|
||||
dependencies = [
|
||||
"rustc_version",
|
||||
"simdutf8",
|
||||
]
|
||||
|
||||
[[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.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
@@ -2527,6 +4956,54 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "softbuffer"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"js-sys",
|
||||
"ndk",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-foundation",
|
||||
"objc2-quartz-core",
|
||||
"raw-window-handle",
|
||||
"redox_syscall 0.5.18",
|
||||
"tracing",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "soup3"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"gio",
|
||||
"glib",
|
||||
"libc",
|
||||
"soup3-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "soup3-sys"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27"
|
||||
dependencies = [
|
||||
"gio-sys",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.8"
|
||||
@@ -2536,6 +5013,36 @@ dependencies = [
|
||||
"lock_api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
|
||||
dependencies = [
|
||||
"new_debug_unreachable",
|
||||
"parking_lot 0.12.5",
|
||||
"phf_shared",
|
||||
"precomputed-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "string_cache_codegen"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
@@ -2567,6 +5074,27 @@ version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "swift-rs"
|
||||
version = "1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e45c444e496845d3f2a351146bff59aae4975b2280238df1dfaa0c7d1846f38e"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.109"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.114"
|
||||
@@ -2578,11 +5106,25 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sync_wrapper"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
@@ -2592,7 +5134,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2605,6 +5147,19 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "6.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349"
|
||||
dependencies = [
|
||||
"cfg-expr",
|
||||
"heck 0.5.0",
|
||||
"pkg-config",
|
||||
"toml 0.8.2",
|
||||
"version-compare",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tachyonix"
|
||||
version = "0.3.1"
|
||||
@@ -2618,19 +5173,398 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tao"
|
||||
version = "0.35.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"block2",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics",
|
||||
"crossbeam-channel",
|
||||
"dbus",
|
||||
"dispatch2",
|
||||
"dlopen2",
|
||||
"dpi",
|
||||
"gdkwayland-sys",
|
||||
"gdkx11-sys",
|
||||
"gtk",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"log",
|
||||
"ndk",
|
||||
"ndk-sys",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
"objc2-ui-kit",
|
||||
"once_cell",
|
||||
"parking_lot 0.12.5",
|
||||
"percent-encoding",
|
||||
"raw-window-handle",
|
||||
"tao-macros",
|
||||
"unicode-segmentation",
|
||||
"url",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
"windows-version",
|
||||
"x11-dl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tao-macros"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.12.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
|
||||
|
||||
[[package]]
|
||||
name = "tauri"
|
||||
version = "2.11.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"dirs",
|
||||
"dunce",
|
||||
"embed_plist",
|
||||
"getrandom 0.3.4",
|
||||
"glob",
|
||||
"gtk",
|
||||
"heck 0.5.0",
|
||||
"http",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"log",
|
||||
"mime",
|
||||
"muda",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
"objc2-ui-kit",
|
||||
"objc2-web-kit",
|
||||
"percent-encoding",
|
||||
"plist",
|
||||
"raw-window-handle",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"serialize-to-javascript",
|
||||
"swift-rs",
|
||||
"tauri-build",
|
||||
"tauri-macros",
|
||||
"tauri-runtime",
|
||||
"tauri-runtime-wry",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tray-icon",
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"window-vibrancy",
|
||||
"windows 0.61.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-build"
|
||||
version = "2.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cargo_toml",
|
||||
"dirs",
|
||||
"glob",
|
||||
"heck 0.5.0",
|
||||
"json-patch",
|
||||
"schemars 0.8.22",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri-utils",
|
||||
"tauri-winres",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-codegen"
|
||||
version = "2.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"brotli",
|
||||
"ico",
|
||||
"json-patch",
|
||||
"plist",
|
||||
"png 0.17.16",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"syn 2.0.114",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"url",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-macros"
|
||||
version = "2.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"tauri-codegen",
|
||||
"tauri-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin"
|
||||
version = "2.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"glob",
|
||||
"plist",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri-utils",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-opener"
|
||||
version = "2.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"glob",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
"open",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
"windows 0.61.3",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-process"
|
||||
version = "2.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a"
|
||||
dependencies = [
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-single-instance"
|
||||
version = "2.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3214becf9ef5783c0ae99a3bb25adf5353a7a16ebf53e74b909e29205735c6c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-updater"
|
||||
version = "2.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"dirs",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"http",
|
||||
"infer",
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"osakit",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tar",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"tokio",
|
||||
"url",
|
||||
"windows-sys 0.60.2",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8"
|
||||
dependencies = [
|
||||
"cookie",
|
||||
"dpi",
|
||||
"gtk",
|
||||
"http",
|
||||
"jni 0.21.1",
|
||||
"objc2",
|
||||
"objc2-ui-kit",
|
||||
"objc2-web-kit",
|
||||
"raw-window-handle",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"windows 0.61.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime-wry"
|
||||
version = "2.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"http",
|
||||
"jni 0.21.1",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"raw-window-handle",
|
||||
"softbuffer",
|
||||
"tao",
|
||||
"tauri-runtime",
|
||||
"tauri-utils",
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"windows 0.61.3",
|
||||
"wry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-utils"
|
||||
version = "2.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"brotli",
|
||||
"cargo_metadata",
|
||||
"ctor",
|
||||
"dom_query",
|
||||
"dunce",
|
||||
"glob",
|
||||
"http",
|
||||
"infer",
|
||||
"json-patch",
|
||||
"log",
|
||||
"memchr",
|
||||
"phf",
|
||||
"plist",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"schemars 0.8.22",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde-untagged",
|
||||
"serde_json",
|
||||
"serde_with",
|
||||
"swift-rs",
|
||||
"thiserror 2.0.18",
|
||||
"toml 1.0.6+spec-1.1.0",
|
||||
"url",
|
||||
"urlpattern",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-winres"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"embed-resource",
|
||||
"toml 1.0.6+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tcp_ip"
|
||||
version = "0.1.10"
|
||||
source = "git+https://github.com/rustp2p/tcp_ip#09c64c81da70a35248dfef4ef42bb9f5e8b6e05e"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a3b3309aae4733df008397c448ddeff5d54dfbcc6c19950b43272cef668f0fe"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"dashmap",
|
||||
"flume 0.11.1",
|
||||
"flume",
|
||||
"log",
|
||||
"num_enum",
|
||||
"parking_lot 0.12.5",
|
||||
"pnet_packet",
|
||||
"rand",
|
||||
"rand 0.10.2",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
]
|
||||
@@ -2648,6 +5582,15 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tendril"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"
|
||||
dependencies = [
|
||||
"new_debug_unreachable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termcolor"
|
||||
version = "1.4.1"
|
||||
@@ -2683,7 +5626,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2694,7 +5637,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2749,6 +5692,16 @@ dependencies = [
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
version = "1.10.0"
|
||||
@@ -2789,7 +5742,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2804,9 +5757,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.28.0"
|
||||
version = "0.30.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
|
||||
checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
@@ -2827,19 +5780,55 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.3",
|
||||
"toml_edit 0.20.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.9.11+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"serde_spanned 1.0.4",
|
||||
"toml_datetime 0.7.5+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow",
|
||||
"winnow 0.7.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.0.6+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "399b1124a3c9e16766831c6bba21e50192572cdd98706ea114f9502509686ffc"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"serde_core",
|
||||
"serde_spanned 1.0.4",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow 0.7.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2851,25 +5840,58 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.19.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"toml_datetime 0.6.3",
|
||||
"winnow 0.5.40",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"serde",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.3",
|
||||
"winnow 0.5.40",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.23.10+spec-1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"toml_datetime",
|
||||
"indexmap 2.13.0",
|
||||
"toml_datetime 0.7.5+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"winnow",
|
||||
"winnow 0.7.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.0.6+spec-1.1.0"
|
||||
version = "1.1.3+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44"
|
||||
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
|
||||
dependencies = [
|
||||
"winnow",
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2896,28 +5918,35 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.6.8"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
||||
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"http-range-header",
|
||||
"httpdate",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"bytes",
|
||||
"http",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2952,7 +5981,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2994,6 +6023,34 @@ dependencies = [
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tray-icon"
|
||||
version = "0.24.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dirs",
|
||||
"libappindicator",
|
||||
"muda",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-foundation",
|
||||
"once_cell",
|
||||
"png 0.18.1",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "try-lock"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tun-rs"
|
||||
version = "2.8.1"
|
||||
@@ -3010,7 +6067,7 @@ dependencies = [
|
||||
"getifaddrs",
|
||||
"ipnet",
|
||||
"libc",
|
||||
"libloading",
|
||||
"libloading 0.9.0",
|
||||
"log",
|
||||
"netconfig-rs",
|
||||
"nix 0.31.1",
|
||||
@@ -3024,19 +6081,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.28.0"
|
||||
version = "0.30.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
|
||||
checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand",
|
||||
"rand 0.10.2",
|
||||
"sha1",
|
||||
"thiserror 2.0.18",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3045,6 +6101,12 @@ version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c"
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
||||
|
||||
[[package]]
|
||||
name = "typemap-ors"
|
||||
version = "1.0.0"
|
||||
@@ -3056,9 +6118,61 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unic-char-property"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221"
|
||||
dependencies = [
|
||||
"unic-char-range",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unic-char-range"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc"
|
||||
|
||||
[[package]]
|
||||
name = "unic-common"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc"
|
||||
|
||||
[[package]]
|
||||
name = "unic-ucd-ident"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987"
|
||||
dependencies = [
|
||||
"unic-char-property",
|
||||
"unic-char-range",
|
||||
"unic-ucd-version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unic-ucd-version"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4"
|
||||
dependencies = [
|
||||
"unic-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
@@ -3112,10 +6226,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "utf-8"
|
||||
version = "0.7.6"
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"idna",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urlpattern"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"serde",
|
||||
"unic-ucd-ident",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
@@ -3131,6 +6270,7 @@ checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"js-sys",
|
||||
"serde_core",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
@@ -3140,6 +6280,12 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "version-compare"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
@@ -3148,7 +6294,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "vnt-core"
|
||||
version = "2.0.0"
|
||||
version = "2.0.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -3157,6 +6303,7 @@ dependencies = [
|
||||
"getifaddrs",
|
||||
"hex",
|
||||
"ipnet",
|
||||
"libc",
|
||||
"log",
|
||||
"lz4_flex",
|
||||
"machine-uid",
|
||||
@@ -3164,8 +6311,9 @@ dependencies = [
|
||||
"pnet_packet",
|
||||
"prost",
|
||||
"prost-build",
|
||||
"protoc-bin-vendored",
|
||||
"quinn",
|
||||
"rand",
|
||||
"rand 0.10.2",
|
||||
"rcgen",
|
||||
"reed-solomon-erasure",
|
||||
"ring",
|
||||
@@ -3173,7 +6321,7 @@ dependencies = [
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"serde",
|
||||
"sha2",
|
||||
"sha2 0.11.0",
|
||||
"socket2 0.6.2",
|
||||
"tcp_ip",
|
||||
"time",
|
||||
@@ -3186,12 +6334,34 @@ dependencies = [
|
||||
"uuid",
|
||||
"widestring",
|
||||
"winapi",
|
||||
"windows-sys 0.61.2",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vnt-desktop"
|
||||
version = "2.0.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-updater",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"toml 0.9.11+spec-1.1.0",
|
||||
"vnt-web",
|
||||
"vnt2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vnt-ipc"
|
||||
version = "2.0.0"
|
||||
version = "2.0.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cli-table",
|
||||
@@ -3200,15 +6370,33 @@ dependencies = [
|
||||
"log",
|
||||
"prost",
|
||||
"prost-build",
|
||||
"protoc-bin-vendored",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"vnt-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vnt-jni"
|
||||
version = "2.0.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hostname",
|
||||
"ipnet",
|
||||
"jni 0.21.1",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"parking_lot 0.12.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"vnt-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vnt-web"
|
||||
version = "2.0.0"
|
||||
version = "2.0.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -3217,20 +6405,23 @@ dependencies = [
|
||||
"log",
|
||||
"mime_guess",
|
||||
"parking_lot 0.12.5",
|
||||
"rand 0.10.2",
|
||||
"route_manager",
|
||||
"rust-embed",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"time",
|
||||
"tokio",
|
||||
"toml",
|
||||
"tower-http",
|
||||
"tokio-util",
|
||||
"toml 0.9.11+spec-1.1.0",
|
||||
"tower",
|
||||
"tower-http 0.7.0",
|
||||
"vnt-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vnt2"
|
||||
version = "2.0.0"
|
||||
version = "2.0.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -3241,11 +6432,31 @@ dependencies = [
|
||||
"route_manager",
|
||||
"serde",
|
||||
"tokio",
|
||||
"toml",
|
||||
"toml 0.9.11+spec-1.1.0",
|
||||
"vnt-ipc",
|
||||
"vnt-web",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vswhom"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"vswhom-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vswhom-sys"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "waker-fn"
|
||||
version = "1.2.0"
|
||||
@@ -3262,6 +6473,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "want"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
|
||||
dependencies = [
|
||||
"try-lock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
@@ -3290,6 +6510,20 @@ dependencies = [
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.108"
|
||||
@@ -3309,7 +6543,7 @@ dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.114",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
@@ -3322,6 +6556,29 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.85"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
@@ -3332,6 +6589,107 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web_atoms"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3"
|
||||
dependencies = [
|
||||
"phf",
|
||||
"phf_codegen",
|
||||
"string_cache",
|
||||
"string_cache_codegen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webkit2gtk"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"cairo-rs",
|
||||
"gdk",
|
||||
"gdk-sys",
|
||||
"gio",
|
||||
"gio-sys",
|
||||
"glib",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"gtk",
|
||||
"gtk-sys",
|
||||
"javascriptcore-rs",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"soup3",
|
||||
"webkit2gtk-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webkit2gtk-sys"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"cairo-sys-rs",
|
||||
"gdk-sys",
|
||||
"gio-sys",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"gtk-sys",
|
||||
"javascriptcore-rs-sys",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"soup3-sys",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.38.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
|
||||
dependencies = [
|
||||
"webview2-com-macros",
|
||||
"webview2-com-sys",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com-macros"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com-sys"
|
||||
version = "0.38.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
|
||||
dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "widestring"
|
||||
version = "1.2.1"
|
||||
@@ -3369,6 +6727,21 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "window-vibrancy"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"raw-window-handle",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.48.0"
|
||||
@@ -3445,7 +6818,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3456,7 +6829,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3528,6 +6901,15 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.45.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
|
||||
dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
@@ -3564,6 +6946,21 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.42.2",
|
||||
"windows_aarch64_msvc 0.42.2",
|
||||
"windows_i686_gnu 0.42.2",
|
||||
"windows_i686_msvc 0.42.2",
|
||||
"windows_x86_64_gnu 0.42.2",
|
||||
"windows_x86_64_gnullvm 0.42.2",
|
||||
"windows_x86_64_msvc 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.48.5"
|
||||
@@ -3621,6 +7018,21 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-version"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.48.5"
|
||||
@@ -3639,6 +7051,12 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.48.5"
|
||||
@@ -3657,6 +7075,12 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.48.5"
|
||||
@@ -3687,6 +7111,12 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.48.5"
|
||||
@@ -3705,6 +7135,12 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.48.5"
|
||||
@@ -3723,6 +7159,12 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.48.5"
|
||||
@@ -3741,6 +7183,12 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.48.5"
|
||||
@@ -3759,6 +7207,15 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.5.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.14"
|
||||
@@ -3768,6 +7225,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.55.0"
|
||||
@@ -3784,6 +7250,77 @@ version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
||||
|
||||
[[package]]
|
||||
name = "wry"
|
||||
version = "0.55.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"block2",
|
||||
"cookie",
|
||||
"crossbeam-channel",
|
||||
"dirs",
|
||||
"dom_query",
|
||||
"dpi",
|
||||
"dunce",
|
||||
"gdkx11",
|
||||
"gtk",
|
||||
"http",
|
||||
"javascriptcore-rs",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"ndk",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"objc2-ui-kit",
|
||||
"objc2-web-kit",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"raw-window-handle",
|
||||
"sha2 0.10.9",
|
||||
"soup3",
|
||||
"tao-macros",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
"webkit2gtk-sys",
|
||||
"webview2-com",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
"windows-version",
|
||||
"x11-dl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11"
|
||||
version = "2.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11-dl"
|
||||
version = "2.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"once_cell",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x509-parser"
|
||||
version = "0.18.1"
|
||||
@@ -3802,6 +7339,16 @@ dependencies = [
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yasna"
|
||||
version = "0.5.2"
|
||||
@@ -3811,6 +7358,99 @@ dependencies = [
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
"yoke-derive",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke-derive"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "5.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
|
||||
dependencies = [
|
||||
"async-broadcast",
|
||||
"async-executor",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-process",
|
||||
"async-recursion",
|
||||
"async-task",
|
||||
"async-trait",
|
||||
"blocking",
|
||||
"enumflags2",
|
||||
"event-listener",
|
||||
"futures-core",
|
||||
"futures-lite",
|
||||
"hex",
|
||||
"libc",
|
||||
"ordered-stream",
|
||||
"rustix",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"winnow 1.0.4",
|
||||
"zbus_macros",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_macros"
|
||||
version = "5.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.4.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_names"
|
||||
version = "4.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"winnow 1.0.4",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zcheapstr"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.39"
|
||||
@@ -3828,7 +7468,28 @@ checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
|
||||
dependencies = [
|
||||
"zerofrom-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom-derive"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3837,8 +7498,94 @@ version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec"
|
||||
version = "0.11.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
|
||||
dependencies = [
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec-derive"
|
||||
version = "0.11.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"indexmap 2.13.0",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445"
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147"
|
||||
dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"winnow 1.0.4",
|
||||
"zcheapstr",
|
||||
"zvariant_derive",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_derive"
|
||||
version = "5.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.4.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_utils"
|
||||
version = "4.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
"syn 3.0.3",
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
[package]
|
||||
name = "vnt2"
|
||||
version = "2.0.0"
|
||||
version = "2.0.2"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
vnt-ipc = { path = "vnt-ipc", optional = true }
|
||||
vnt-web = { path = "vnt-web", optional = true }
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
clap = { version = "4.5", features = ["derive", "env"] }
|
||||
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
|
||||
|
||||
log = "0.4"
|
||||
log.workspace = true
|
||||
log4rs = "1.4"
|
||||
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
anyhow = "1.0.100"
|
||||
toml = "0.9.8"
|
||||
hostname = "0.4.2"
|
||||
ipnet = { version = "2.11", features = ["serde"] }
|
||||
serde.workspace = true
|
||||
anyhow.workspace = true
|
||||
toml.workspace = true
|
||||
hostname.workspace = true
|
||||
ipnet.workspace = true
|
||||
|
||||
route_manager = "0.2.11"
|
||||
route_manager.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
@@ -43,7 +43,26 @@ path = "src/main_web.rs"
|
||||
required-features = ["vnt-web"]
|
||||
|
||||
[workspace]
|
||||
members = ["vnt-web", "vnt-ipc", "vnt-core"]
|
||||
members = ["vnt-web", "vnt-ipc", "vnt-core", "vnt-jni", "vnt-desktop/src-tauri"]
|
||||
|
||||
[workspace.dependencies]
|
||||
vnt-core = { path = "vnt-core" }
|
||||
|
||||
tokio = "1"
|
||||
tokio-util = "0.7"
|
||||
futures = "0.3"
|
||||
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
anyhow = "1"
|
||||
log = "0.4"
|
||||
toml = "0.9"
|
||||
time = "0.3"
|
||||
hostname = "0.4"
|
||||
ipnet = { version = "2.11", features = ["serde"] }
|
||||
route_manager = "0.2"
|
||||
parking_lot = "0.12"
|
||||
prost = "0.14"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 'z'
|
||||
@@ -51,7 +70,6 @@ debug = 0
|
||||
debug-assertions = false
|
||||
strip = true
|
||||
lto = true
|
||||
panic = 'abort'
|
||||
incremental = false
|
||||
codegen-units = 1
|
||||
rpath = false
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
1. vnt2_cli 是一个纯命令行组网工具,可以从命令行参数或配置文件快速启动组网
|
||||
2. vnt2_ctrl 和vnt2_cli搭配使用,vnt2_cli后台运行时,可以用vnt2_ctrl来获取组网状态
|
||||
3. vnt2_web 是一个集成web服务的组网工具,带web页面,可以在页面上操作组网
|
||||
4. `vnt-desktop` 是基于 Tauri 2 的 PC 客户端,内置组网服务、桌面工作台和系统托盘
|
||||
|
||||
## 使用vnt2_cli组网
|
||||
|
||||
@@ -30,9 +31,32 @@
|
||||
# 启动程序
|
||||
./vnt2_web
|
||||
```
|
||||
2. 浏览器打开 http://127.0.0.1:19099
|
||||
2. 从启动日志复制带 `?token=...` 的 Web 访问地址;也可以通过 `--token` 或 `VNT_WEB_TOKEN` 指定固定令牌
|
||||
3. 在页面上添加组网配置,再启动组网
|
||||
|
||||
## 前端构建
|
||||
|
||||
web 前端源码位于 `vnt-web/ui/`(Vite + Vue 3 + Pinia + Tailwind CSS v4),构建产物输出到 `vnt-web/static/`,由 RustEmbed 嵌入二进制。
|
||||
|
||||
项目使用根级 pnpm workspace 统一管理 Web 与桌面前端依赖:
|
||||
|
||||
```
|
||||
pnpm install
|
||||
pnpm build:web
|
||||
```
|
||||
|
||||
开发调试使用 `pnpm dev:web`,Vite dev server 会将 `/api` 代理到 `127.0.0.1:19099`。启动 `vnt2_web` 时应指定令牌,并在浏览器登录页输入相同令牌。
|
||||
|
||||
## PC 客户端
|
||||
|
||||
桌面客户端源码位于 `vnt-desktop/`(Tauri 2 + Vue 3)。桌面工作台通过 Tauri IPC 直接调用进程内 `vnt-core`;需要浏览器访问时,可在“Web 访问”中按需启用同进程 HTTP 服务,无需单独运行 `vnt2_web`。Tauri 与 Web 端统一使用 `vnt-web/ui/src/` 下的同一套响应式前端代码。
|
||||
|
||||
```
|
||||
pnpm install
|
||||
pnpm dev:desktop
|
||||
```
|
||||
|
||||
构建安装包使用 `pnpm build:desktop`。更多说明见 `vnt-desktop/README.md`。
|
||||
|
||||
# VNT2.0新特性
|
||||
|
||||
@@ -40,12 +64,22 @@
|
||||
2. 提升流量稳定性,支持使用quic代理流量,支持FEC冗余传输
|
||||
3. 简化操作,去除了大量vnt1.0的重复和无用的配置参数
|
||||
4. vnt-link、vnt合二为一
|
||||
5. 支持有tun模式、无tun模式、端口映射
|
||||
5. 支持无网卡、TUN(三层)和 TAP(二层)模式及端口映射;三种模式的 IPv4 流量可互通
|
||||
6. 全功能的情况下,减少程序体积
|
||||
7. 性能提升,支持linux-offload
|
||||
8. 更规范的api接入,可以轻松自定义客户端
|
||||
9. 支持同时连接多个服务端,可以容灾和负载均衡
|
||||
|
||||
## 虚拟网卡模式
|
||||
|
||||
配置文件使用 `device_mode = "no|tun|tap"`,默认值为 `tun`;命令行可用 `--device-mode` 覆盖。旧的 `no_tun` 配置已移除,程序会提示迁移而不会静默按 TUN 启动。
|
||||
|
||||
- `no`:不创建虚拟网卡,只提供流量出口和端口映射。
|
||||
- `tun`:创建三层网卡,网卡收发 IPv4 包。
|
||||
- `tap`:创建二层网卡,完整透传 Ethernet 帧,并与 TUN/NO 节点转换 IPv4、兼容 ARP。
|
||||
|
||||
Linux 和 macOS 使用系统提供的 TUN/TAP 能力。Windows 的 TUN 模式使用随程序提供的 `wintun.dll`;TAP 模式需要管理员权限并预先安装 `tap-windows`(硬件 ID `tap0901`)。Android VpnService 仅支持 TUN。
|
||||
|
||||
# 说明
|
||||
|
||||
vnt2.0整体重构了一遍,和1.0不兼容,同时也可能引入新的bug,欢迎反馈
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "vnt-workspace",
|
||||
"private": true,
|
||||
"packageManager": "[email protected]",
|
||||
"scripts": {
|
||||
"dev:web": "pnpm --filter vnt-web-ui dev",
|
||||
"build:web": "pnpm --filter vnt-web-ui build",
|
||||
"dev:desktop-ui": "pnpm --filter vnt-desktop dev",
|
||||
"dev:desktop": "pnpm --filter vnt-desktop tauri dev",
|
||||
"build:desktop-ui": "pnpm --filter vnt-desktop build",
|
||||
"build:desktop": "pnpm --filter vnt-desktop tauri build"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,1733 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
catalogs:
|
||||
default:
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.12
|
||||
version: 4.3.3
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: ^6.0.1
|
||||
version: 6.0.8
|
||||
pinia:
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.4
|
||||
tailwindcss:
|
||||
specifier: ^4.1.12
|
||||
version: 4.3.3
|
||||
vite:
|
||||
specifier: ^7.1.3
|
||||
version: 7.3.6
|
||||
vue:
|
||||
specifier: ^3.5.18
|
||||
version: 3.5.41
|
||||
vue-router:
|
||||
specifier: ^4.5.1
|
||||
version: 4.6.4
|
||||
|
||||
importers:
|
||||
|
||||
.: {}
|
||||
|
||||
vnt-desktop:
|
||||
dependencies:
|
||||
'@tauri-apps/api':
|
||||
specifier: ^2.8.0
|
||||
version: 2.11.1
|
||||
'@tauri-apps/plugin-process':
|
||||
specifier: ^2.3.1
|
||||
version: 2.3.1
|
||||
'@tauri-apps/plugin-updater':
|
||||
specifier: ^2.10.1
|
||||
version: 2.10.1
|
||||
pinia:
|
||||
specifier: 'catalog:'
|
||||
version: 3.0.4([email protected])
|
||||
vue:
|
||||
specifier: 'catalog:'
|
||||
version: 3.5.41
|
||||
vue-router:
|
||||
specifier: 'catalog:'
|
||||
version: 4.6.4([email protected])
|
||||
devDependencies:
|
||||
'@tailwindcss/vite':
|
||||
specifier: 'catalog:'
|
||||
version: 4.3.3([email protected]([email protected])([email protected]))
|
||||
'@tauri-apps/cli':
|
||||
specifier: ^2.8.4
|
||||
version: 2.11.4
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: 'catalog:'
|
||||
version: 6.0.8([email protected]([email protected])([email protected]))([email protected])
|
||||
tailwindcss:
|
||||
specifier: 'catalog:'
|
||||
version: 4.3.3
|
||||
vite:
|
||||
specifier: 'catalog:'
|
||||
version: 7.3.6([email protected])([email protected])
|
||||
|
||||
vnt-web/ui:
|
||||
dependencies:
|
||||
pinia:
|
||||
specifier: 'catalog:'
|
||||
version: 3.0.4([email protected])
|
||||
qrcode:
|
||||
specifier: 1.5.4
|
||||
version: 1.5.4
|
||||
vue:
|
||||
specifier: 'catalog:'
|
||||
version: 3.5.41
|
||||
vue-router:
|
||||
specifier: 'catalog:'
|
||||
version: 4.6.4([email protected])
|
||||
devDependencies:
|
||||
'@tailwindcss/vite':
|
||||
specifier: 'catalog:'
|
||||
version: 4.3.3([email protected]([email protected])([email protected]))
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: 'catalog:'
|
||||
version: 6.0.8([email protected]([email protected])([email protected]))([email protected])
|
||||
tailwindcss:
|
||||
specifier: 'catalog:'
|
||||
version: 4.3.3
|
||||
vite:
|
||||
specifier: 'catalog:'
|
||||
version: 7.3.6([email protected])([email protected])
|
||||
|
||||
packages:
|
||||
|
||||
'@babel/[email protected]':
|
||||
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/[email protected]':
|
||||
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/[email protected]':
|
||||
resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@babel/[email protected]':
|
||||
resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@jridgewell/[email protected]':
|
||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||
|
||||
'@jridgewell/[email protected]':
|
||||
resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
|
||||
|
||||
'@jridgewell/[email protected]':
|
||||
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
'@jridgewell/[email protected]':
|
||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||
|
||||
'@jridgewell/[email protected]':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
|
||||
engines: {node: ^22.20 || ^24.12 || >=25}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/[email protected]':
|
||||
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA==}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA==}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ==}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ==}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg==}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA==}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==}
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
cpu: [wasm32]
|
||||
bundledDependencies:
|
||||
- '@napi-rs/wasm-runtime'
|
||||
- '@emnapi/core'
|
||||
- '@emnapi/runtime'
|
||||
- '@tybys/wasm-util'
|
||||
- '@emnapi/wasi-threads'
|
||||
- tslib
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==}
|
||||
engines: {node: '>= 20'}
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==}
|
||||
peerDependencies:
|
||||
vite: ^5.2.0 || ^6 || ^7 || ^8
|
||||
|
||||
'@tauri-apps/[email protected]':
|
||||
resolution: {integrity: sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==}
|
||||
|
||||
'@tauri-apps/[email protected]':
|
||||
resolution: {integrity: sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@tauri-apps/[email protected]':
|
||||
resolution: {integrity: sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@tauri-apps/[email protected]':
|
||||
resolution: {integrity: sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@tauri-apps/[email protected]':
|
||||
resolution: {integrity: sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tauri-apps/[email protected]':
|
||||
resolution: {integrity: sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tauri-apps/[email protected]':
|
||||
resolution: {integrity: sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tauri-apps/[email protected]':
|
||||
resolution: {integrity: sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tauri-apps/[email protected]':
|
||||
resolution: {integrity: sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tauri-apps/[email protected]':
|
||||
resolution: {integrity: sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@tauri-apps/[email protected]':
|
||||
resolution: {integrity: sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@tauri-apps/[email protected]':
|
||||
resolution: {integrity: sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@tauri-apps/[email protected]':
|
||||
resolution: {integrity: sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==}
|
||||
engines: {node: '>= 10'}
|
||||
hasBin: true
|
||||
|
||||
'@tauri-apps/[email protected]':
|
||||
resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==}
|
||||
|
||||
'@tauri-apps/[email protected]':
|
||||
resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||
|
||||
'@vitejs/[email protected]':
|
||||
resolution: {integrity: sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
peerDependencies:
|
||||
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
vue: ^3.2.25
|
||||
|
||||
'@vue/[email protected]':
|
||||
resolution: {integrity: sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==}
|
||||
|
||||
'@vue/[email protected]':
|
||||
resolution: {integrity: sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==}
|
||||
|
||||
'@vue/[email protected]':
|
||||
resolution: {integrity: sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==}
|
||||
|
||||
'@vue/[email protected]':
|
||||
resolution: {integrity: sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==}
|
||||
|
||||
'@vue/[email protected]':
|
||||
resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
|
||||
|
||||
'@vue/[email protected]':
|
||||
resolution: {integrity: sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==}
|
||||
|
||||
'@vue/[email protected]':
|
||||
resolution: {integrity: sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==}
|
||||
|
||||
'@vue/[email protected]':
|
||||
resolution: {integrity: sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==}
|
||||
|
||||
'@vue/[email protected]':
|
||||
resolution: {integrity: sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==}
|
||||
|
||||
'@vue/[email protected]':
|
||||
resolution: {integrity: sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==}
|
||||
|
||||
'@vue/[email protected]':
|
||||
resolution: {integrity: sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==}
|
||||
|
||||
'@vue/[email protected]':
|
||||
resolution: {integrity: sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==}
|
||||
|
||||
'@vue/[email protected]':
|
||||
resolution: {integrity: sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ufbM3smX/Jbnpk5wcQjzd1MgBpzmqfNETUAyZNrGwU9foRlyHoGzMMBBCRzEhQLBjZfFDE1W2ufPXX2vdWkV8Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
peerDependencies:
|
||||
picomatch: ^3 || ^4
|
||||
peerDependenciesMeta:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||
engines: {node: 6.* || 8.* || >= 10.*}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==}
|
||||
peerDependencies:
|
||||
typescript: '>=4.5.0'
|
||||
vue: ^3.5.11
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==}
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@types/node': ^20.19.0 || >=22.12.0
|
||||
jiti: '>=1.21.0'
|
||||
less: ^4.0.0
|
||||
lightningcss: ^1.21.0
|
||||
sass: ^1.70.0
|
||||
sass-embedded: ^1.70.0
|
||||
stylus: '>=0.54.8'
|
||||
sugarss: ^5.0.0
|
||||
terser: ^5.16.0
|
||||
tsx: ^4.8.1
|
||||
yaml: ^2.4.2
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
jiti:
|
||||
optional: true
|
||||
less:
|
||||
optional: true
|
||||
lightningcss:
|
||||
optional: true
|
||||
sass:
|
||||
optional: true
|
||||
sass-embedded:
|
||||
optional: true
|
||||
stylus:
|
||||
optional: true
|
||||
sugarss:
|
||||
optional: true
|
||||
terser:
|
||||
optional: true
|
||||
tsx:
|
||||
optional: true
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
|
||||
peerDependencies:
|
||||
vue: ^3.5.0
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==}
|
||||
peerDependencies:
|
||||
typescript: '*'
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@babel/[email protected]': {}
|
||||
|
||||
'@babel/[email protected]': {}
|
||||
|
||||
'@babel/[email protected]':
|
||||
dependencies:
|
||||
'@babel/types': 7.29.8
|
||||
|
||||
'@babel/[email protected]':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 7.29.7
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@jridgewell/[email protected]':
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
|
||||
'@jridgewell/[email protected]':
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
|
||||
'@jridgewell/[email protected]': {}
|
||||
|
||||
'@jridgewell/[email protected]': {}
|
||||
|
||||
'@jridgewell/[email protected]':
|
||||
dependencies:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]': {}
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@rollup/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
enhanced-resolve: 5.24.5
|
||||
jiti: 2.7.0
|
||||
lightningcss: 1.32.0
|
||||
magic-string: 0.30.21
|
||||
source-map-js: 1.2.1
|
||||
tailwindcss: 4.3.3
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
optionalDependencies:
|
||||
'@tailwindcss/oxide-android-arm64': 4.3.3
|
||||
'@tailwindcss/oxide-darwin-arm64': 4.3.3
|
||||
'@tailwindcss/oxide-darwin-x64': 4.3.3
|
||||
'@tailwindcss/oxide-freebsd-x64': 4.3.3
|
||||
'@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3
|
||||
'@tailwindcss/oxide-linux-arm64-gnu': 4.3.3
|
||||
'@tailwindcss/oxide-linux-arm64-musl': 4.3.3
|
||||
'@tailwindcss/oxide-linux-x64-gnu': 4.3.3
|
||||
'@tailwindcss/oxide-linux-x64-musl': 4.3.3
|
||||
'@tailwindcss/oxide-wasm32-wasi': 4.3.3
|
||||
'@tailwindcss/oxide-win32-arm64-msvc': 4.3.3
|
||||
'@tailwindcss/oxide-win32-x64-msvc': 4.3.3
|
||||
|
||||
'@tailwindcss/[email protected]([email protected]([email protected])([email protected]))':
|
||||
dependencies:
|
||||
'@tailwindcss/node': 4.3.3
|
||||
'@tailwindcss/oxide': 4.3.3
|
||||
tailwindcss: 4.3.3
|
||||
vite: 7.3.6([email protected])([email protected])
|
||||
|
||||
'@tauri-apps/[email protected]': {}
|
||||
|
||||
'@tauri-apps/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/[email protected]':
|
||||
optionalDependencies:
|
||||
'@tauri-apps/cli-darwin-arm64': 2.11.4
|
||||
'@tauri-apps/cli-darwin-x64': 2.11.4
|
||||
'@tauri-apps/cli-linux-arm-gnueabihf': 2.11.4
|
||||
'@tauri-apps/cli-linux-arm64-gnu': 2.11.4
|
||||
'@tauri-apps/cli-linux-arm64-musl': 2.11.4
|
||||
'@tauri-apps/cli-linux-riscv64-gnu': 2.11.4
|
||||
'@tauri-apps/cli-linux-x64-gnu': 2.11.4
|
||||
'@tauri-apps/cli-linux-x64-musl': 2.11.4
|
||||
'@tauri-apps/cli-win32-arm64-msvc': 2.11.4
|
||||
'@tauri-apps/cli-win32-ia32-msvc': 2.11.4
|
||||
'@tauri-apps/cli-win32-x64-msvc': 2.11.4
|
||||
|
||||
'@tauri-apps/[email protected]':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.11.1
|
||||
|
||||
'@tauri-apps/[email protected]':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.11.1
|
||||
|
||||
'@types/[email protected]': {}
|
||||
|
||||
'@vitejs/[email protected]([email protected]([email protected])([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 7.3.6([email protected])([email protected])
|
||||
vue: 3.5.41
|
||||
|
||||
'@vue/[email protected]':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.8
|
||||
'@vue/shared': 3.5.41
|
||||
entities: 7.0.1
|
||||
estree-walker: 2.0.2
|
||||
source-map-js: 1.2.1
|
||||
|
||||
'@vue/[email protected]':
|
||||
dependencies:
|
||||
'@vue/compiler-core': 3.5.41
|
||||
'@vue/shared': 3.5.41
|
||||
|
||||
'@vue/[email protected]':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.8
|
||||
'@vue/compiler-core': 3.5.41
|
||||
'@vue/compiler-dom': 3.5.41
|
||||
'@vue/compiler-ssr': 3.5.41
|
||||
'@vue/shared': 3.5.41
|
||||
estree-walker: 2.0.2
|
||||
magic-string: 0.30.21
|
||||
postcss: 8.5.26
|
||||
source-map-js: 1.2.1
|
||||
|
||||
'@vue/[email protected]':
|
||||
dependencies:
|
||||
'@vue/compiler-dom': 3.5.41
|
||||
'@vue/shared': 3.5.41
|
||||
|
||||
'@vue/[email protected]': {}
|
||||
|
||||
'@vue/[email protected]':
|
||||
dependencies:
|
||||
'@vue/devtools-kit': 7.7.10
|
||||
|
||||
'@vue/[email protected]':
|
||||
dependencies:
|
||||
'@vue/devtools-shared': 7.7.10
|
||||
birpc: 2.9.0
|
||||
hookable: 5.5.3
|
||||
mitt: 3.0.1
|
||||
perfect-debounce: 1.0.0
|
||||
speakingurl: 14.0.1
|
||||
superjson: 2.2.6
|
||||
|
||||
'@vue/[email protected]':
|
||||
dependencies:
|
||||
rfdc: 1.4.1
|
||||
|
||||
'@vue/[email protected]':
|
||||
dependencies:
|
||||
'@vue/shared': 3.5.41
|
||||
|
||||
'@vue/[email protected]':
|
||||
dependencies:
|
||||
'@vue/reactivity': 3.5.41
|
||||
'@vue/shared': 3.5.41
|
||||
|
||||
'@vue/[email protected]':
|
||||
dependencies:
|
||||
'@vue/reactivity': 3.5.41
|
||||
'@vue/runtime-core': 3.5.41
|
||||
'@vue/shared': 3.5.41
|
||||
csstype: 3.2.3
|
||||
|
||||
'@vue/[email protected]':
|
||||
dependencies:
|
||||
'@vue/compiler-ssr': 3.5.41
|
||||
'@vue/runtime-dom': 3.5.41
|
||||
'@vue/shared': 3.5.41
|
||||
|
||||
'@vue/[email protected]': {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
color-convert: 2.0.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
wrap-ansi: 6.2.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
tapable: 2.3.3
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.28.2
|
||||
'@esbuild/android-arm': 0.28.2
|
||||
'@esbuild/android-arm64': 0.28.2
|
||||
'@esbuild/android-x64': 0.28.2
|
||||
'@esbuild/darwin-arm64': 0.28.2
|
||||
'@esbuild/darwin-x64': 0.28.2
|
||||
'@esbuild/freebsd-arm64': 0.28.2
|
||||
'@esbuild/freebsd-x64': 0.28.2
|
||||
'@esbuild/linux-arm': 0.28.2
|
||||
'@esbuild/linux-arm64': 0.28.2
|
||||
'@esbuild/linux-ia32': 0.28.2
|
||||
'@esbuild/linux-loong64': 0.28.2
|
||||
'@esbuild/linux-mips64el': 0.28.2
|
||||
'@esbuild/linux-ppc64': 0.28.2
|
||||
'@esbuild/linux-riscv64': 0.28.2
|
||||
'@esbuild/linux-s390x': 0.28.2
|
||||
'@esbuild/linux-x64': 0.28.2
|
||||
'@esbuild/netbsd-arm64': 0.28.2
|
||||
'@esbuild/netbsd-x64': 0.28.2
|
||||
'@esbuild/openbsd-arm64': 0.28.2
|
||||
'@esbuild/openbsd-x64': 0.28.2
|
||||
'@esbuild/openharmony-arm64': 0.28.2
|
||||
'@esbuild/sunos-x64': 0.28.2
|
||||
'@esbuild/win32-arm64': 0.28.2
|
||||
'@esbuild/win32-ia32': 0.28.2
|
||||
'@esbuild/win32-x64': 0.28.2
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]([email protected]):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.5
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
locate-path: 5.0.0
|
||||
path-exists: 4.0.0
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
detect-libc: 2.1.2
|
||||
optionalDependencies:
|
||||
lightningcss-android-arm64: 1.32.0
|
||||
lightningcss-darwin-arm64: 1.32.0
|
||||
lightningcss-darwin-x64: 1.32.0
|
||||
lightningcss-freebsd-x64: 1.32.0
|
||||
lightningcss-linux-arm-gnueabihf: 1.32.0
|
||||
lightningcss-linux-arm64-gnu: 1.32.0
|
||||
lightningcss-linux-arm64-musl: 1.32.0
|
||||
lightningcss-linux-x64-gnu: 1.32.0
|
||||
lightningcss-linux-x64-musl: 1.32.0
|
||||
lightningcss-win32-arm64-msvc: 1.32.0
|
||||
lightningcss-win32-x64-msvc: 1.32.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
p-locate: 4.1.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
p-try: 2.2.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
p-limit: 2.3.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]([email protected]):
|
||||
dependencies:
|
||||
'@vue/devtools-api': 7.7.10
|
||||
vue: 3.5.41
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
nanoid: 3.3.18
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
dijkstrajs: 1.0.3
|
||||
pngjs: 5.0.0
|
||||
yargs: 15.4.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.9
|
||||
optionalDependencies:
|
||||
'@napi-rs/lzma-linux-x64-gnu': 1.5.1
|
||||
'@rollup/rollup-android-arm-eabi': 4.62.5
|
||||
'@rollup/rollup-android-arm64': 4.62.5
|
||||
'@rollup/rollup-darwin-arm64': 4.62.5
|
||||
'@rollup/rollup-darwin-x64': 4.62.5
|
||||
'@rollup/rollup-freebsd-arm64': 4.62.5
|
||||
'@rollup/rollup-freebsd-x64': 4.62.5
|
||||
'@rollup/rollup-linux-arm-gnueabihf': 4.62.5
|
||||
'@rollup/rollup-linux-arm-musleabihf': 4.62.5
|
||||
'@rollup/rollup-linux-arm64-gnu': 4.62.5
|
||||
'@rollup/rollup-linux-arm64-musl': 4.62.5
|
||||
'@rollup/rollup-linux-loong64-gnu': 4.62.5
|
||||
'@rollup/rollup-linux-loong64-musl': 4.62.5
|
||||
'@rollup/rollup-linux-ppc64-gnu': 4.62.5
|
||||
'@rollup/rollup-linux-ppc64-musl': 4.62.5
|
||||
'@rollup/rollup-linux-riscv64-gnu': 4.62.5
|
||||
'@rollup/rollup-linux-riscv64-musl': 4.62.5
|
||||
'@rollup/rollup-linux-s390x-gnu': 4.62.5
|
||||
'@rollup/rollup-linux-x64-gnu': 4.62.5
|
||||
'@rollup/rollup-linux-x64-musl': 4.62.5
|
||||
'@rollup/rollup-openbsd-x64': 4.62.5
|
||||
'@rollup/rollup-openharmony-arm64': 4.62.5
|
||||
'@rollup/rollup-win32-arm64-msvc': 4.62.5
|
||||
'@rollup/rollup-win32-ia32-msvc': 4.62.5
|
||||
'@rollup/rollup-win32-x64-gnu': 4.62.5
|
||||
'@rollup/rollup-win32-x64-msvc': 4.62.5
|
||||
fsevents: 2.3.3
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
emoji-regex: 8.0.0
|
||||
is-fullwidth-code-point: 3.0.0
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
ansi-regex: 5.0.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
copy-anything: 4.1.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
fdir: 6.5.0([email protected])
|
||||
picomatch: 4.0.5
|
||||
|
||||
[email protected]([email protected])([email protected]):
|
||||
dependencies:
|
||||
esbuild: 0.28.2
|
||||
fdir: 6.5.0([email protected])
|
||||
picomatch: 4.0.5
|
||||
postcss: 8.5.26
|
||||
rollup: 4.62.5
|
||||
tinyglobby: 0.2.17
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
jiti: 2.7.0
|
||||
lightningcss: 1.32.0
|
||||
|
||||
[email protected]([email protected]):
|
||||
dependencies:
|
||||
'@vue/devtools-api': 6.6.4
|
||||
vue: 3.5.41
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@vue/compiler-dom': 3.5.41
|
||||
'@vue/compiler-sfc': 3.5.41
|
||||
'@vue/runtime-dom': 3.5.41
|
||||
'@vue/server-renderer': 3.5.41
|
||||
'@vue/shared': 3.5.41
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
camelcase: 5.3.1
|
||||
decamelize: 1.2.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
cliui: 6.0.0
|
||||
decamelize: 1.2.0
|
||||
find-up: 4.1.0
|
||||
get-caller-file: 2.0.5
|
||||
require-directory: 2.1.1
|
||||
require-main-filename: 2.0.0
|
||||
set-blocking: 2.0.0
|
||||
string-width: 4.2.3
|
||||
which-module: 2.0.1
|
||||
y18n: 4.0.3
|
||||
yargs-parser: 18.1.3
|
||||
@@ -0,0 +1,12 @@
|
||||
packages:
|
||||
- "vnt-web/ui"
|
||||
- "vnt-desktop"
|
||||
|
||||
catalog:
|
||||
"@tailwindcss/vite": ^4.1.12
|
||||
"@vitejs/plugin-vue": ^6.0.1
|
||||
pinia: ^3.0.3
|
||||
tailwindcss: ^4.1.12
|
||||
vite: ^7.1.3
|
||||
vue: ^3.5.18
|
||||
vue-router: ^4.5.1
|
||||
@@ -4,7 +4,7 @@ use ipnet::Ipv4Net;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::Ipv4Addr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use vnt_core::context::config::Config;
|
||||
use vnt_core::context::config::{Config, DeviceMode};
|
||||
use vnt_core::nat::NetInput;
|
||||
use vnt_core::tls::verifier::CertValidationMode;
|
||||
use vnt_core::tunnel_core::server::transport::config::ProtocolAddress;
|
||||
@@ -23,7 +23,9 @@ pub struct FileConfig {
|
||||
pub input: Option<Vec<NetInput>>,
|
||||
pub output: Option<Vec<Ipv4Net>>,
|
||||
pub no_nat: Option<bool>,
|
||||
pub no_tun: Option<bool>,
|
||||
pub device_mode: Option<DeviceMode>,
|
||||
#[serde(rename = "no_tun", skip_serializing)]
|
||||
pub legacy_no_tun: Option<bool>,
|
||||
pub mtu: Option<u16>,
|
||||
pub ctrl_port: Option<u16>,
|
||||
pub port_mapping: Option<Vec<String>>,
|
||||
@@ -31,10 +33,12 @@ pub struct FileConfig {
|
||||
pub device_id: Option<String>,
|
||||
pub device_name: Option<String>,
|
||||
pub tun_name: Option<String>,
|
||||
pub outbound_interface: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub cert_mode: Option<String>,
|
||||
pub udp_stun: Option<Vec<String>>,
|
||||
pub tcp_stun: Option<Vec<String>>,
|
||||
pub tunnel_port: Option<u16>,
|
||||
}
|
||||
|
||||
impl FileConfig {
|
||||
@@ -126,12 +130,15 @@ pub struct Args {
|
||||
/// 虚拟网卡名称
|
||||
#[clap(long)]
|
||||
pub tun_name: Option<String>,
|
||||
/// 绑定对外通信 Socket 的出口网卡名称
|
||||
#[clap(long)]
|
||||
pub outbound_interface: Option<String>,
|
||||
/// 关闭内置子网NAT
|
||||
#[clap(long)]
|
||||
pub no_nat: bool,
|
||||
/// 禁用tun,禁用后只能充当流量出口或者进行端口映射,无需管理员权限
|
||||
/// 虚拟网卡模式:no(无网卡)、tun(三层网卡)、tap(二层网卡)
|
||||
#[clap(long)]
|
||||
pub no_tun: bool,
|
||||
pub device_mode: Option<DeviceMode>,
|
||||
/// 端口映射,格式为:协议://本地监听地址-目标虚拟IP-目标映射地址
|
||||
#[clap(long)]
|
||||
pub port_mapping: Vec<PortMapping>,
|
||||
@@ -144,6 +151,9 @@ pub struct Args {
|
||||
/// 控制端口,设置0时禁用控制服务
|
||||
#[clap(long)]
|
||||
pub ctrl_port: Option<u16>,
|
||||
/// 隧道端口,用于P2P通信
|
||||
#[clap(long)]
|
||||
pub tunnel_port: Option<u16>,
|
||||
/// 读取配置文件
|
||||
#[arg(long)]
|
||||
pub conf: Option<PathBuf>,
|
||||
@@ -169,6 +179,14 @@ pub fn build_config_from_args_and_file(
|
||||
args: Option<Args>,
|
||||
file: Option<FileConfig>,
|
||||
) -> anyhow::Result<(Config, CtrlConfig)> {
|
||||
if file
|
||||
.as_ref()
|
||||
.is_some_and(|config| config.legacy_no_tun.is_some())
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"configuration key 'no_tun' was removed; use device_mode = \"no|tun|tap\""
|
||||
));
|
||||
}
|
||||
match (args, file) {
|
||||
(Some(args), Some(file)) => build_from_args_and_file(args, file),
|
||||
(Some(args), None) => build_from_args_only(args),
|
||||
@@ -242,17 +260,21 @@ fn build_from_args_and_file(args: Args, file: FileConfig) -> anyhow::Result<(Con
|
||||
.or_else(|| file.device_name.clone())
|
||||
.unwrap_or_else(default_hostname),
|
||||
tun_name: args.tun_name.or_else(|| file.tun_name.clone()),
|
||||
outbound_interface: args
|
||||
.outbound_interface
|
||||
.or_else(|| file.outbound_interface.clone()),
|
||||
password: args.password.or_else(|| file.password.clone()),
|
||||
cert_mode,
|
||||
input,
|
||||
output,
|
||||
no_nat: args.no_nat || file.no_nat.unwrap_or(false),
|
||||
no_tun: args.no_tun || file.no_tun.unwrap_or(false),
|
||||
device_mode: args.device_mode.or(file.device_mode).unwrap_or_default(),
|
||||
mtu: args.mtu.or(file.mtu),
|
||||
port_mapping,
|
||||
allow_port_mapping: args.allow_mapping || file.allow_mapping.unwrap_or(false),
|
||||
udp_stun,
|
||||
tcp_stun,
|
||||
tunnel_port: args.tunnel_port.or(file.tunnel_port),
|
||||
};
|
||||
|
||||
let ctrl_config = CtrlConfig {
|
||||
@@ -280,16 +302,18 @@ fn build_from_args_only(args: Args) -> anyhow::Result<(Config, CtrlConfig)> {
|
||||
device_id,
|
||||
device_name: args.device_name.unwrap_or_else(default_hostname),
|
||||
tun_name: args.tun_name,
|
||||
outbound_interface: args.outbound_interface,
|
||||
password: args.password,
|
||||
cert_mode: args
|
||||
.cert_mode
|
||||
.unwrap_or(CertValidationMode::InsecureSkipVerification),
|
||||
output: args.output,
|
||||
no_nat: args.no_nat,
|
||||
no_tun: args.no_tun,
|
||||
device_mode: args.device_mode.unwrap_or_default(),
|
||||
mtu: args.mtu,
|
||||
port_mapping: args.port_mapping,
|
||||
allow_port_mapping: args.allow_mapping,
|
||||
tunnel_port: args.tunnel_port,
|
||||
..Default::default()
|
||||
};
|
||||
let ctrl_config = CtrlConfig {
|
||||
@@ -299,6 +323,11 @@ fn build_from_args_only(args: Args) -> anyhow::Result<(Config, CtrlConfig)> {
|
||||
}
|
||||
|
||||
fn build_from_file_only(file: FileConfig) -> anyhow::Result<(Config, CtrlConfig)> {
|
||||
if file.legacy_no_tun.is_some() {
|
||||
return Err(anyhow!(
|
||||
"configuration key 'no_tun' was removed; use device_mode = \"no|tun|tap\""
|
||||
));
|
||||
}
|
||||
let server_addr = file.to_server_addr()?;
|
||||
let port_mapping = file.to_port_mapping()?;
|
||||
|
||||
@@ -339,16 +368,18 @@ fn build_from_file_only(file: FileConfig) -> anyhow::Result<(Config, CtrlConfig)
|
||||
device_id,
|
||||
device_name: file.device_name.clone().unwrap_or_else(default_hostname),
|
||||
tun_name: file.tun_name.clone(),
|
||||
outbound_interface: file.outbound_interface.clone(),
|
||||
password: file.password.clone(),
|
||||
cert_mode,
|
||||
output: file.output.unwrap_or_default(),
|
||||
no_nat: file.no_nat.unwrap_or(false),
|
||||
no_tun: file.no_tun.unwrap_or(false),
|
||||
device_mode: file.device_mode.unwrap_or_default(),
|
||||
mtu: file.mtu,
|
||||
port_mapping,
|
||||
allow_port_mapping: file.allow_mapping.unwrap_or(false),
|
||||
udp_stun,
|
||||
tcp_stun,
|
||||
tunnel_port: file.tunnel_port,
|
||||
};
|
||||
let ctrl_config = CtrlConfig {
|
||||
ctrl_port: file.ctrl_port,
|
||||
@@ -406,8 +437,8 @@ server = ["quic://1.2.3.4:29872"]
|
||||
# 是否关闭内置子网NAT,关闭(设为true)后需要配置网卡转发,否则无法使用点对网。通常关闭内置子网NAT,使用系统的网卡转发,点对网性能会更好
|
||||
# no_nat = false
|
||||
|
||||
# 是否关闭TUN虚拟网卡,关闭(设为true)后只能充当流量出口或者进行端口映射,关闭后无需管理员权限
|
||||
# no_tun = false
|
||||
# 虚拟网卡模式:no(无网卡)、tun(三层网卡,默认)、tap(二层网卡)
|
||||
# device_mode = "tun"
|
||||
|
||||
# 端口映射,格式为:协议://本地监听地址-目标虚拟IP-目标映射地址
|
||||
# 端口映射用于在本地监听指定端口,并将收到的网络流量经由指定虚拟节点转发到目标地址,从而实现跨网络或内网服务访问
|
||||
@@ -424,6 +455,9 @@ server = ["quic://1.2.3.4:29872"]
|
||||
# 控制服务的 tcp 端口
|
||||
# ctrl_port = 11233
|
||||
|
||||
# 隧道端口,用于P2P通信 (默认为0,自动分配)
|
||||
# tunnel_port = 0
|
||||
|
||||
# MTU 设置
|
||||
# mtu = 1400
|
||||
|
||||
@@ -438,6 +472,9 @@ server = ["quic://1.2.3.4:29872"]
|
||||
# 虚拟网卡名称
|
||||
# tun_name = "vnt-tun"
|
||||
|
||||
# 绑定对外通信 Socket 的出口网卡名称(用于服务端通信、P2P 打洞及转发流量)
|
||||
# outbound_interface = "Ethernet"
|
||||
|
||||
# --- 安全配置 ---
|
||||
|
||||
# 加密密码 (可选)
|
||||
@@ -466,3 +503,74 @@ server = ["quic://1.2.3.4:29872"]
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 纯参数模式下 --tunnel-port 不能被静默丢弃
|
||||
#[test]
|
||||
fn test_args_only_keeps_tunnel_port() {
|
||||
let args = Args::try_parse_from([
|
||||
"vnt",
|
||||
"-s",
|
||||
"quic://127.0.0.1:29872",
|
||||
"-n",
|
||||
"test-net",
|
||||
"--tunnel-port",
|
||||
"12345",
|
||||
"--outbound-interface",
|
||||
"Ethernet",
|
||||
])
|
||||
.unwrap();
|
||||
let (config, _) = build_from_args_only(args).unwrap();
|
||||
assert_eq!(config.tunnel_port, Some(12345));
|
||||
assert_eq!(config.outbound_interface.as_deref(), Some("Ethernet"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_device_mode_cli_and_legacy_rejection() {
|
||||
let args = Args::try_parse_from([
|
||||
"vnt",
|
||||
"-s",
|
||||
"quic://127.0.0.1:29872",
|
||||
"-n",
|
||||
"test-net",
|
||||
"--device-mode",
|
||||
"tap",
|
||||
])
|
||||
.unwrap();
|
||||
let (config, _) = build_from_args_only(args).unwrap();
|
||||
assert_eq!(config.device_mode, DeviceMode::Tap);
|
||||
|
||||
let legacy: FileConfig = toml::from_str("no_tun = true").unwrap();
|
||||
let error = match build_config_from_args_and_file(None, Some(legacy)) {
|
||||
Err(error) => error,
|
||||
Ok(_) => panic!("legacy no_tun must be rejected"),
|
||||
};
|
||||
assert!(error.to_string().contains("device_mode"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_device_mode_cli_overrides_file_and_file_defaults() {
|
||||
let file: FileConfig = toml::from_str("device_mode = \"no\"").unwrap();
|
||||
let args = Args::try_parse_from(["vnt", "-s", "quic://127.0.0.1:29872", "-n", "test-net"])
|
||||
.unwrap();
|
||||
let (config, _) = build_config_from_args_and_file(Some(args), Some(file)).unwrap();
|
||||
assert_eq!(config.device_mode, DeviceMode::No);
|
||||
|
||||
let file: FileConfig = toml::from_str("device_mode = \"no\"").unwrap();
|
||||
let args = Args::try_parse_from([
|
||||
"vnt",
|
||||
"-s",
|
||||
"quic://127.0.0.1:29872",
|
||||
"-n",
|
||||
"test-net",
|
||||
"--device-mode",
|
||||
"tap",
|
||||
])
|
||||
.unwrap();
|
||||
let (config, _) = build_config_from_args_and_file(Some(args), Some(file)).unwrap();
|
||||
assert_eq!(config.device_mode, DeviceMode::Tap);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,9 +25,59 @@ fn extract_wintun_impl() -> io::Result<()> {
|
||||
.and_then(|p| p.parent().map(|d| d.join("wintun.dll")))
|
||||
.unwrap_or_else(|| Path::new("wintun.dll").to_path_buf());
|
||||
|
||||
if !path.exists() {
|
||||
let mut file = fs::File::create(&path)?;
|
||||
file.write_all(WINTUN_DLL)?;
|
||||
}
|
||||
ensure_dll(&path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 确保 path 处的 dll 与内嵌版本一致,不一致(不存在/损坏/旧版)时重写。
|
||||
/// 返回是否发生了写入。只按存在性判断会让损坏或旧版 dll 永久残留。
|
||||
fn ensure_dll(path: &Path) -> io::Result<bool> {
|
||||
let up_to_date = fs::read(path)
|
||||
.map(|content| content.as_slice() == WINTUN_DLL)
|
||||
.unwrap_or(false);
|
||||
if up_to_date {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut file = fs::File::create(path)?;
|
||||
file.write_all(WINTUN_DLL)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp_dll_path(tag: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"vnt_wintun_test_{}_{}.dll",
|
||||
std::process::id(),
|
||||
tag
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_dll_writes_when_missing() {
|
||||
let path = temp_dll_path("missing");
|
||||
let _ = fs::remove_file(&path);
|
||||
assert!(ensure_dll(&path).unwrap());
|
||||
assert_eq!(fs::read(&path).unwrap(), WINTUN_DLL);
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_dll_rewrites_corrupt() {
|
||||
let path = temp_dll_path("corrupt");
|
||||
fs::write(&path, b"corrupt").unwrap();
|
||||
assert!(ensure_dll(&path).unwrap());
|
||||
assert_eq!(fs::read(&path).unwrap(), WINTUN_DLL);
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_dll_skips_when_up_to_date() {
|
||||
let path = temp_dll_path("uptodate");
|
||||
fs::write(&path, WINTUN_DLL).unwrap();
|
||||
assert!(!ensure_dll(&path).unwrap());
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
use anyhow::Context;
|
||||
use args_config::{build_config_from_args_and_file, Args, FileConfig};
|
||||
use anyhow::{Context, bail};
|
||||
use args_config::{Args, FileConfig, build_config_from_args_and_file};
|
||||
use route_manager::Route;
|
||||
use std::path::Path;
|
||||
use vnt_ipc as vnt_core;
|
||||
|
||||
use vnt_core::core::NetworkManager;
|
||||
use vnt_core::utils::task_control::TaskGroupManager;
|
||||
use vnt_ipc::core::RegisterResponse;
|
||||
|
||||
pub mod args_config;
|
||||
|
||||
#[cfg(windows)]
|
||||
@@ -83,20 +85,45 @@ async fn main0() -> anyhow::Result<()> {
|
||||
let mut network_manager = NetworkManager::create_network(Box::new(config), task_group)
|
||||
.await
|
||||
.context("create network")?;
|
||||
let reg_msg = network_manager.register().await.context("register")?;
|
||||
|
||||
if !network_manager.is_no_tun() {
|
||||
log::info!("启动网络:{}/{}", reg_msg.ip, reg_msg.prefix_len);
|
||||
network_manager.start_tun().await.context("start tun")?;
|
||||
let reg_msg = loop {
|
||||
let reg_msg = match network_manager.register().await {
|
||||
Ok(rs) => rs,
|
||||
Err(e) => {
|
||||
log::error!("Register failed: {:?}", e);
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match reg_msg {
|
||||
RegisterResponse::Success(reg_msg) => {
|
||||
break reg_msg;
|
||||
}
|
||||
RegisterResponse::Failed(e) => {
|
||||
log::error!("Register failed: {:?}", e);
|
||||
bail!("注册失败:{}", e.message)
|
||||
}
|
||||
}
|
||||
};
|
||||
if network_manager.device_mode().has_device() {
|
||||
log::info!(
|
||||
"启动网络:{}/{} ({})",
|
||||
reg_msg.ip,
|
||||
reg_msg.prefix_len,
|
||||
network_manager.device_mode()
|
||||
);
|
||||
network_manager
|
||||
.set_network_ip(reg_msg.ip, reg_msg.prefix_len)
|
||||
.start_device()
|
||||
.await
|
||||
.context("start device")?;
|
||||
network_manager
|
||||
.set_device_network_ip(reg_msg.ip, reg_msg.prefix_len)
|
||||
.await
|
||||
.context("set network ip")?;
|
||||
if !sub_input.is_empty() {
|
||||
let if_index = network_manager
|
||||
.tun_if_index()
|
||||
.device_if_index()
|
||||
.await
|
||||
.context("tun_if_index")?;
|
||||
.context("device_if_index")?;
|
||||
let mut route_manager = route_manager::RouteManager::new()?;
|
||||
for x in sub_input {
|
||||
let route = Route::new(x.net.network().into(), x.net.prefix_len())
|
||||
@@ -124,16 +151,11 @@ async fn main0() -> anyhow::Result<()> {
|
||||
}
|
||||
});
|
||||
}
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = network_manager.wait_all_stopped() => {
|
||||
break;
|
||||
}
|
||||
_ = network_manager.wait_all_stopped() => {}
|
||||
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
log::info!("Ctrl+c received!");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(task_group_guard);
|
||||
|
||||
@@ -15,6 +15,9 @@ struct Args {
|
||||
/// 加载vnt配置路径,配置内容参考web端的配置格式
|
||||
#[clap(long)]
|
||||
conf: Option<PathBuf>,
|
||||
/// Web API 访问令牌;未指定时会生成随机令牌并输出到日志
|
||||
#[clap(long, env = "VNT_WEB_TOKEN")]
|
||||
token: Option<String>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -31,6 +34,19 @@ async fn main0() -> anyhow::Result<()> {
|
||||
#[cfg(windows)]
|
||||
extract_wintun_dll::extract_wintun();
|
||||
let addr = args.addr.unwrap_or("127.0.0.1:19099".parse()?);
|
||||
vnt_web::run_http_server(addr, args.conf).await?;
|
||||
let token = args.token.unwrap_or_else(vnt_web::generate_access_token);
|
||||
let browser_host = if addr.ip().is_unspecified() {
|
||||
"127.0.0.1".to_string()
|
||||
} else {
|
||||
addr.ip().to_string()
|
||||
};
|
||||
log::info!("Web access token: {}", token);
|
||||
log::info!(
|
||||
"Web access URL: http://{}:{}/?token={}",
|
||||
browser_host,
|
||||
addr.port(),
|
||||
token
|
||||
);
|
||||
vnt_web::run_http_server(addr, args.conf, token).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,51 +1,49 @@
|
||||
[package]
|
||||
name = "vnt-core"
|
||||
version = "2.0.0"
|
||||
version = "2.0.2"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-util = { version = "0.7", features = ["codec"] }
|
||||
futures = "0.3"
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
tokio-util = { workspace = true, features = ["codec"] }
|
||||
futures.workspace = true
|
||||
|
||||
tun-rs = { version = "2", features = ["async", "async_framed"] }
|
||||
|
||||
rust-p2p-core = { version="0.4" }
|
||||
tcp_ip = { git = "https://github.com/rustp2p/tcp_ip" }
|
||||
tcp_ip = "0.2"
|
||||
|
||||
anyhow = "1"
|
||||
parking_lot = "0.12"
|
||||
anyhow.workspace = true
|
||||
parking_lot.workspace = true
|
||||
|
||||
quinn = { version = "0.11", default-features = false, features = ["rustls", "runtime-tokio"] }
|
||||
|
||||
tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring"] }
|
||||
hex = "0.4"
|
||||
sha2 = "0.10"
|
||||
sha2 = "0.11"
|
||||
rustls-native-certs = "0.8.2"
|
||||
log = "0.4"
|
||||
log.workspace = true
|
||||
bytes = "1.11.0"
|
||||
rand = "0.9"
|
||||
time = { version = "0.3", features = ["macros", "formatting", "local-offset"] }
|
||||
rand = "0.10"
|
||||
time = { workspace = true, features = ["macros", "formatting", "local-offset"] }
|
||||
pnet_packet = "0.35"
|
||||
ring = "0.17.14"
|
||||
|
||||
prost = "0.14"
|
||||
tokio-tungstenite = "0.28.0"
|
||||
tungstenite = "0.28.0"
|
||||
prost.workspace = true
|
||||
tokio-tungstenite = "0.30"
|
||||
tungstenite = "0.30"
|
||||
uuid = { version = "1.18.1", features = ["v4"] }
|
||||
ipnet = { version = "2.11", features = ["serde"] }
|
||||
ipnet.workspace = true
|
||||
getifaddrs = "0.6.0"
|
||||
dns-parser = "0.8"
|
||||
|
||||
lz4_flex = "0.12.0"
|
||||
lz4_flex = "0.14"
|
||||
reed-solomon-erasure = "6.0"
|
||||
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde.workspace = true
|
||||
rcgen = "0.14.6"
|
||||
machine-uid = "0.5.4"
|
||||
|
||||
zerocopy = { version = "0.8.31", features = ["derive"] }
|
||||
socket2 = { version = "0.6.1", features = ["all"] }
|
||||
@@ -53,6 +51,14 @@ socket2 = { version = "0.6.1", features = ["all"] }
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
winapi = { version = "0.3.9", features = ["winreg"] }
|
||||
widestring = "1.2"
|
||||
windows-sys = { version = "0.61", features = ["Win32_Networking_WinSock"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||
machine-uid = "0.6"
|
||||
|
||||
[build-dependencies]
|
||||
prost-build = "0.14"
|
||||
protoc-bin-vendored = "3"
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
fn main() {
|
||||
let mut config = prost_build::Config::new();
|
||||
|
||||
match protoc_bin_vendored::protoc_bin_path() {
|
||||
Ok(protoc_path) => {
|
||||
config.protoc_executable(protoc_path);
|
||||
}
|
||||
Err(error) => {
|
||||
println!(
|
||||
"cargo:warning=vendored protoc unavailable ({error:?}); falling back to protoc from PATH"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
config.protoc_arg("--experimental_allow_proto3_optional");
|
||||
|
||||
config
|
||||
.compile_protos(
|
||||
&[
|
||||
|
||||
@@ -67,14 +67,26 @@ impl VntApi {
|
||||
pub fn peer_nat_info(&self, ip: &Ipv4Addr) -> Option<NatInfo> {
|
||||
self.app_state.get_peer_info(ip).and_then(|v| v.nat_info)
|
||||
}
|
||||
/// 获取指定 IP 的聚合丢包信息(所有路由合并)
|
||||
pub fn packet_loss_info(&self, ip: &Ipv4Addr) -> Option<PacketLossInfo> {
|
||||
self.app_state.packet_loss_stats.get_loss_info(ip)
|
||||
self.app_state
|
||||
.packet_loss_stats
|
||||
.get_aggregated_loss_info(ip)
|
||||
}
|
||||
/// 获取指定 IP 的所有路由的丢包信息
|
||||
pub fn packet_loss_info_by_routes(&self, ip: &Ipv4Addr) -> Vec<PacketLossInfo> {
|
||||
self.app_state.packet_loss_stats.get_loss_info_by_ip(ip)
|
||||
}
|
||||
pub fn all_packet_loss_info(&self) -> Vec<PacketLossInfo> {
|
||||
self.app_state.packet_loss_stats.get_all_loss_info()
|
||||
}
|
||||
pub fn reset_packet_loss(&self, ip: &Ipv4Addr) {
|
||||
self.app_state.packet_loss_stats.reset(ip)
|
||||
// 重置该 IP 的所有路由统计
|
||||
for info in self.app_state.packet_loss_stats.get_loss_info_by_ip(ip) {
|
||||
if let Some(route_key) = info.route_key {
|
||||
self.app_state.packet_loss_stats.reset(ip, &route_key);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn reset_all_packet_loss(&self) {
|
||||
self.app_state.packet_loss_stats.reset_all()
|
||||
|
||||
@@ -89,11 +89,13 @@ mod tests {
|
||||
|
||||
// --- 构造原始包 ---
|
||||
let payload = vec![1u8; 200];
|
||||
let original = make_packet(&payload);
|
||||
let mut original = make_packet(&payload);
|
||||
original.set_ethernet_flag(true);
|
||||
|
||||
// --- 压缩 ---
|
||||
let compressed = lz.compress(original, 0).unwrap();
|
||||
assert!(compressed.is_compressed());
|
||||
assert!(compressed.is_ethernet());
|
||||
|
||||
// 压缩后的 payload 应变小
|
||||
assert!(
|
||||
@@ -106,11 +108,14 @@ mod tests {
|
||||
|
||||
// 标志应清除
|
||||
assert!(!decompressed.is_compressed());
|
||||
assert!(decompressed.is_ethernet());
|
||||
|
||||
// HEAD 不变
|
||||
let mut expected_head = [0u8; HEAD_LENGTH];
|
||||
expected_head[2] = 0x10;
|
||||
assert_eq!(
|
||||
&decompressed.buffer()[..HEAD_LENGTH],
|
||||
&[0u8; HEAD_LENGTH][..],
|
||||
&expected_head,
|
||||
"HEAD 必须保持不变"
|
||||
);
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ use crate::tunnel_core::server::transport::config::{ConnectRegConfig, ProtocolAd
|
||||
use anyhow::bail;
|
||||
use ipnet::Ipv4Net;
|
||||
use std::collections::HashSet;
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::net::Ipv4Addr;
|
||||
use std::str::FromStr;
|
||||
|
||||
pub const MAX_NETWORK_CODE_LEN: usize = 32;
|
||||
pub const MAX_DEVICE_ID_LEN: usize = 64;
|
||||
@@ -14,6 +16,44 @@ pub const MAX_NAME_LEN: usize = 128;
|
||||
pub const MAX_VERSION_LEN: usize = 32;
|
||||
pub const MAX_MTU: u16 = 1500;
|
||||
|
||||
#[derive(Debug, Copy, Clone, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DeviceMode {
|
||||
No,
|
||||
#[default]
|
||||
Tun,
|
||||
Tap,
|
||||
}
|
||||
|
||||
impl DeviceMode {
|
||||
pub fn has_device(self) -> bool {
|
||||
!matches!(self, Self::No)
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for DeviceMode {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::No => "no",
|
||||
Self::Tun => "tun",
|
||||
Self::Tap => "tap",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for DeviceMode {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
match value.to_ascii_lowercase().as_str() {
|
||||
"no" => Ok(Self::No),
|
||||
"tun" => Ok(Self::Tun),
|
||||
"tap" => Ok(Self::Tap),
|
||||
_ => bail!("invalid device_mode '{value}', expected one of: no, tun, tap"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Config {
|
||||
pub server_addr: Vec<ProtocolAddress>,
|
||||
@@ -22,6 +62,8 @@ pub struct Config {
|
||||
pub device_id: String,
|
||||
pub device_name: String,
|
||||
pub tun_name: Option<String>,
|
||||
/// 绑定 VNT 对外通信 Socket 的物理网卡名称。
|
||||
pub outbound_interface: Option<String>,
|
||||
pub ip: Option<Ipv4Addr>,
|
||||
pub password: Option<String>,
|
||||
pub no_punch: bool,
|
||||
@@ -31,15 +73,20 @@ pub struct Config {
|
||||
pub input: Vec<NetInput>,
|
||||
pub output: Vec<Ipv4Net>,
|
||||
pub no_nat: bool,
|
||||
pub no_tun: bool,
|
||||
pub device_mode: DeviceMode,
|
||||
pub mtu: Option<u16>,
|
||||
pub port_mapping: Vec<PortMapping>,
|
||||
pub allow_port_mapping: bool,
|
||||
pub udp_stun: Vec<String>,
|
||||
pub tcp_stun: Vec<String>,
|
||||
pub tunnel_port: Option<u16>,
|
||||
}
|
||||
impl Config {
|
||||
pub fn check(&self) -> anyhow::Result<()> {
|
||||
#[cfg(any(target_os = "android", target_os = "ios", target_os = "tvos"))]
|
||||
if self.device_mode == DeviceMode::Tap {
|
||||
bail!("TAP mode is not supported on mobile VPN interfaces");
|
||||
}
|
||||
if self.server_addr.is_empty() {
|
||||
bail!("服务器地址不能为空");
|
||||
}
|
||||
@@ -86,7 +133,11 @@ impl Config {
|
||||
pub fn key_sign(&self) -> Option<String> {
|
||||
self.password.as_ref().map(|p| PacketCrypto::key_sign(p))
|
||||
}
|
||||
pub(crate) fn to_connect_config(&self, index: usize) -> ConnectRegConfig {
|
||||
pub(crate) fn to_connect_config(
|
||||
&self,
|
||||
index: usize,
|
||||
default_interface: Option<rust_p2p_core::socket::LocalInterface>,
|
||||
) -> ConnectRegConfig {
|
||||
ConnectRegConfig {
|
||||
server_addr: self.server_addr[index].clone(),
|
||||
cert_mode: self.cert_mode.clone(),
|
||||
@@ -96,6 +147,26 @@ impl Config {
|
||||
ip: self.ip,
|
||||
key_sign: self.key_sign(),
|
||||
ip_variable: self.ip.is_none(),
|
||||
default_interface,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn device_mode_parse_and_display() {
|
||||
for (text, mode) in [
|
||||
("no", DeviceMode::No),
|
||||
("tun", DeviceMode::Tun),
|
||||
("tap", DeviceMode::Tap),
|
||||
] {
|
||||
assert_eq!(text.parse::<DeviceMode>().unwrap(), mode);
|
||||
assert_eq!(mode.to_string(), text);
|
||||
}
|
||||
assert!("bridge".parse::<DeviceMode>().is_err());
|
||||
assert_eq!(DeviceMode::default(), DeviceMode::Tun);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use crate::tunnel_core::server::transport::config::ProtocolAddress;
|
||||
use ipnet::Ipv4Net;
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use rust_p2p_core::nat::NatInfo;
|
||||
use rust_p2p_core::route::RouteKey;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::net::Ipv4Addr;
|
||||
@@ -106,47 +107,59 @@ impl TrafficStats {
|
||||
}
|
||||
}
|
||||
|
||||
type PingStatsMap = HashMap<(Ipv4Addr, RouteKey), Arc<Mutex<PingStats>>>;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct PacketLossStats {
|
||||
inner: Arc<RwLock<HashMap<Ipv4Addr, Arc<Mutex<PingStats>>>>>,
|
||||
inner: Arc<RwLock<PingStatsMap>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct PacketLossInfo {
|
||||
pub ip: Ipv4Addr,
|
||||
#[serde(skip)]
|
||||
pub route_key: Option<RouteKey>,
|
||||
pub sent: u64,
|
||||
pub received: u64,
|
||||
pub loss_rate: f64,
|
||||
}
|
||||
|
||||
impl PacketLossStats {
|
||||
fn get_or_create(&self, ip: Ipv4Addr) -> Arc<Mutex<PingStats>> {
|
||||
fn get_or_create(&self, ip: Ipv4Addr, route_key: RouteKey) -> Arc<Mutex<PingStats>> {
|
||||
{
|
||||
let read = self.inner.read();
|
||||
if let Some(stats) = read.get(&ip) {
|
||||
if let Some(stats) = read.get(&(ip, route_key)) {
|
||||
return stats.clone();
|
||||
}
|
||||
}
|
||||
let mut write = self.inner.write();
|
||||
write
|
||||
.entry(ip)
|
||||
.entry((ip, route_key))
|
||||
.or_insert_with(|| Arc::new(Mutex::new(PingStats::default())))
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub fn record_sent(&self, ip: Ipv4Addr) {
|
||||
let stats = self.get_or_create(ip);
|
||||
pub fn record_sent(&self, ip: Ipv4Addr, route_key: RouteKey) {
|
||||
let stats = self.get_or_create(ip, route_key);
|
||||
stats.lock().sent += 1;
|
||||
}
|
||||
|
||||
pub fn record_received(&self, ip: Ipv4Addr) {
|
||||
let stats = self.get_or_create(ip);
|
||||
stats.lock().received += 1;
|
||||
pub fn record_received(&self, ip: Ipv4Addr, route_key: RouteKey) -> f64 {
|
||||
let stats = self.get_or_create(ip, route_key);
|
||||
let mut guard = stats.lock();
|
||||
guard.received += 1;
|
||||
|
||||
// 计算并返回丢包率
|
||||
if guard.sent > 0 {
|
||||
1.0 - (guard.received as f64 / guard.sent as f64)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_loss_info(&self, ip: &Ipv4Addr) -> Option<PacketLossInfo> {
|
||||
pub fn get_loss_info(&self, ip: &Ipv4Addr, route_key: &RouteKey) -> Option<PacketLossInfo> {
|
||||
let read = self.inner.read();
|
||||
read.get(ip).map(|stats| {
|
||||
read.get(&(*ip, *route_key)).map(|stats| {
|
||||
let guard = stats.lock();
|
||||
let loss_rate = if guard.sent > 0 {
|
||||
1.0 - (guard.received as f64 / guard.sent as f64)
|
||||
@@ -155,6 +168,7 @@ impl PacketLossStats {
|
||||
};
|
||||
PacketLossInfo {
|
||||
ip: *ip,
|
||||
route_key: Some(*route_key),
|
||||
sent: guard.sent,
|
||||
received: guard.received,
|
||||
loss_rate,
|
||||
@@ -162,10 +176,12 @@ impl PacketLossStats {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_all_loss_info(&self) -> Vec<PacketLossInfo> {
|
||||
/// 获取指定 IP 的所有路由的丢包信息
|
||||
pub fn get_loss_info_by_ip(&self, ip: &Ipv4Addr) -> Vec<PacketLossInfo> {
|
||||
let read = self.inner.read();
|
||||
read.iter()
|
||||
.map(|(ip, stats)| {
|
||||
.filter(|((addr, _), _)| addr == ip)
|
||||
.map(|((addr, route_key), stats)| {
|
||||
let guard = stats.lock();
|
||||
let loss_rate = if guard.sent > 0 {
|
||||
1.0 - (guard.received as f64 / guard.sent as f64)
|
||||
@@ -173,7 +189,8 @@ impl PacketLossStats {
|
||||
0.0
|
||||
};
|
||||
PacketLossInfo {
|
||||
ip: *ip,
|
||||
ip: *addr,
|
||||
route_key: Some(*route_key),
|
||||
sent: guard.sent,
|
||||
received: guard.received,
|
||||
loss_rate,
|
||||
@@ -182,13 +199,80 @@ impl PacketLossStats {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn reset(&self, ip: &Ipv4Addr) {
|
||||
/// 获取指定 IP 的聚合丢包信息(所有路由合并)
|
||||
pub fn get_aggregated_loss_info(&self, ip: &Ipv4Addr) -> Option<PacketLossInfo> {
|
||||
let read = self.inner.read();
|
||||
if let Some(stats) = read.get(ip) {
|
||||
let mut total_sent = 0u64;
|
||||
let mut total_received = 0u64;
|
||||
let mut found = false;
|
||||
|
||||
for ((addr, _), stats) in read.iter() {
|
||||
if addr == ip {
|
||||
found = true;
|
||||
let guard = stats.lock();
|
||||
total_sent += guard.sent;
|
||||
total_received += guard.received;
|
||||
}
|
||||
}
|
||||
|
||||
if found {
|
||||
let loss_rate = if total_sent > 0 {
|
||||
1.0 - (total_received as f64 / total_sent as f64)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
Some(PacketLossInfo {
|
||||
ip: *ip,
|
||||
route_key: None,
|
||||
sent: total_sent,
|
||||
received: total_received,
|
||||
loss_rate,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_all_loss_info(&self) -> Vec<PacketLossInfo> {
|
||||
let read = self.inner.read();
|
||||
read.iter()
|
||||
.map(|((ip, route_key), stats)| {
|
||||
let guard = stats.lock();
|
||||
let loss_rate = if guard.sent > 0 {
|
||||
1.0 - (guard.received as f64 / guard.sent as f64)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
PacketLossInfo {
|
||||
ip: *ip,
|
||||
route_key: Some(*route_key),
|
||||
sent: guard.sent,
|
||||
received: guard.received,
|
||||
loss_rate,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn reset(&self, ip: &Ipv4Addr, route_key: &RouteKey) {
|
||||
let read = self.inner.read();
|
||||
if let Some(stats) = read.get(&(*ip, *route_key)) {
|
||||
*stats.lock() = PingStats::default();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(&self, ip: &Ipv4Addr, route_key: &RouteKey) {
|
||||
let mut write = self.inner.write();
|
||||
write.remove(&(*ip, *route_key));
|
||||
}
|
||||
|
||||
pub fn remove_batch(&self, keys: &[(Ipv4Addr, RouteKey)]) {
|
||||
let mut write = self.inner.write();
|
||||
for key in keys {
|
||||
write.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset_all(&self) {
|
||||
let read = self.inner.read();
|
||||
for stats in read.values() {
|
||||
@@ -440,8 +524,8 @@ impl ServerInfoCollection {
|
||||
server_node.client_map.extend(map);
|
||||
}
|
||||
let mut client_simple_map = HashMap::<Ipv4Addr, ClientSimpleInfo>::new();
|
||||
for (_, server_node) in guard.iter() {
|
||||
for (_, x) in server_node.client_map.iter() {
|
||||
for server_node in guard.values() {
|
||||
for x in server_node.client_map.values() {
|
||||
if let Some(v) = client_simple_map.get_mut(&x.ip) {
|
||||
if x.online {
|
||||
v.online = true;
|
||||
@@ -471,7 +555,7 @@ impl ServerInfoCollection {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (_, server_node) in guard.iter() {
|
||||
for server_node in guard.values() {
|
||||
if server_node.connected {
|
||||
return true;
|
||||
}
|
||||
@@ -513,7 +597,7 @@ impl ServerInfoCollection {
|
||||
}
|
||||
pub fn get_server_rtt(&self, ip: &Ipv4Addr) -> Option<u32> {
|
||||
let server_node_map_guard = self.server_node_map.read();
|
||||
for (_, server_node) in server_node_map_guard.iter() {
|
||||
for server_node in server_node_map_guard.values() {
|
||||
if !server_node.connected {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::api::VntApi;
|
||||
use crate::compression::PacketCompression;
|
||||
use crate::context::config::Config;
|
||||
use crate::context::config::{Config, DeviceMode};
|
||||
use crate::context::{AppState, NetworkAddr, NetworkRoute};
|
||||
use crate::crypto::PacketCrypto;
|
||||
use crate::enhanced_tunnel::enhanced_ipv4_tunnel;
|
||||
@@ -9,6 +9,7 @@ use crate::enhanced_tunnel::outbound::EnhancedOutbound;
|
||||
use crate::fec::{FecDecoder, FecEncoder};
|
||||
use crate::nat::internal_nat::{InternalNatInbound, PortMappingManager};
|
||||
use crate::nat::{AllowSubnetExternalRoute, SubnetExternalRoute};
|
||||
use crate::protocol::control_message::ErrorResponseMsg;
|
||||
use crate::tun::enhanced_tun::EnhancedTunInbound;
|
||||
use crate::tun::{DeviceConfig, DeviceIOManager, TunDataInbound, TunReceiver, tun_channel};
|
||||
use crate::tunnel_core::outbound::{BasicOutbound, HybridOutbound};
|
||||
@@ -20,8 +21,9 @@ use crate::tunnel_core::server::connection_manager::{
|
||||
};
|
||||
use crate::tunnel_core::server::rpc::ServerRPC;
|
||||
use crate::utils::task_control::TaskGroup;
|
||||
use anyhow::bail;
|
||||
use anyhow::{Context, bail};
|
||||
use ipnet::Ipv4Net;
|
||||
#[cfg(not(target_os = "android"))]
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
pub const DEFAULT_MTU: u16 = 1380;
|
||||
@@ -47,6 +49,10 @@ pub struct NetworkManager {
|
||||
tun_receiver: Option<TunReceiver>,
|
||||
registration_context: Option<Box<RegistrationContext>>,
|
||||
}
|
||||
pub enum RegisterResponse {
|
||||
Success(NetworkAddr),
|
||||
Failed(ErrorResponseMsg),
|
||||
}
|
||||
|
||||
impl NetworkManager {
|
||||
pub async fn create_network(
|
||||
@@ -55,11 +61,32 @@ impl NetworkManager {
|
||||
) -> anyhow::Result<NetworkManager> {
|
||||
let app_state = AppState::default();
|
||||
config.check()?;
|
||||
let outbound_interface_name = config
|
||||
.outbound_interface
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(str::to_owned);
|
||||
let resolved_interface =
|
||||
crate::utils::socket::resolve_interface(outbound_interface_name.as_deref())?;
|
||||
let default_interface = resolved_interface
|
||||
.as_ref()
|
||||
.map(|interface| interface.socket_interface.clone());
|
||||
let canonical_interface_name = resolved_interface
|
||||
.as_ref()
|
||||
.map(|interface| interface.name.clone());
|
||||
if let Some(name) = canonical_interface_name.as_deref() {
|
||||
log::info!("绑定出口网卡: {name}");
|
||||
}
|
||||
let mtu = config.mtu.unwrap_or(DEFAULT_MTU);
|
||||
let packet_crypto = PacketCrypto::new_from_str(config.password.as_deref());
|
||||
let packet_compression = PacketCompression::new(config.compress);
|
||||
let (server_manager_list, tunnel_to_server, server_rpc) =
|
||||
create_server_tunnel(app_state.clone(), &config, packet_crypto.clone());
|
||||
let (server_manager_list, tunnel_to_server, server_rpc) = create_server_tunnel(
|
||||
app_state.clone(),
|
||||
&config,
|
||||
packet_crypto.clone(),
|
||||
default_interface.clone(),
|
||||
);
|
||||
let device_io_manager = DeviceIOManager::new(task_group.clone());
|
||||
let allow_subnet = AllowSubnetExternalRoute::new(config.output.clone());
|
||||
|
||||
@@ -69,6 +96,9 @@ impl NetworkManager {
|
||||
app_state.clone(),
|
||||
tunnel_to_server.clone(),
|
||||
packet_crypto.clone(),
|
||||
config.tunnel_port,
|
||||
default_interface.clone(),
|
||||
canonical_interface_name,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -107,11 +137,12 @@ impl NetworkManager {
|
||||
fec_encoder,
|
||||
);
|
||||
let port_mapping_manager = PortMappingManager::new(
|
||||
config.no_tun,
|
||||
config.device_mode == DeviceMode::No,
|
||||
config.allow_port_mapping,
|
||||
app_state.network.clone(),
|
||||
default_interface.clone(),
|
||||
);
|
||||
let internal_nat_inbound = if config.no_nat && !config.no_tun {
|
||||
let internal_nat_inbound = if config.no_nat && config.device_mode != DeviceMode::No {
|
||||
None
|
||||
} else {
|
||||
let nat_inbound = InternalNatInbound::create(
|
||||
@@ -120,25 +151,32 @@ impl NetworkManager {
|
||||
hybrid_outbound.clone(),
|
||||
allow_subnet.clone(),
|
||||
app_state.network.clone(),
|
||||
config.no_tun,
|
||||
config.device_mode == DeviceMode::No,
|
||||
default_interface.clone(),
|
||||
)
|
||||
.await?;
|
||||
Some(nat_inbound)
|
||||
};
|
||||
|
||||
let (enhanced_tun_inbound, tun_receiver) = if config.no_tun {
|
||||
(
|
||||
let (enhanced_tun_inbound, tun_receiver) = match config.device_mode {
|
||||
DeviceMode::No => (
|
||||
EnhancedTunInbound::Nat(
|
||||
internal_nat_inbound
|
||||
.clone()
|
||||
.expect("internal_nat_inbound must be Some when no_tun is true"),
|
||||
.expect("internal_nat_inbound must be Some in no-device mode"),
|
||||
),
|
||||
None,
|
||||
)
|
||||
} else {
|
||||
),
|
||||
mode @ (DeviceMode::Tun | DeviceMode::Tap) => {
|
||||
let (tun_inbound, tun_receiver) = tun_channel();
|
||||
let tun_data_sender = TunDataInbound::new(tun_inbound, allow_subnet.clone());
|
||||
(EnhancedTunInbound::Tun(tun_data_sender), Some(tun_receiver))
|
||||
let tun_data_sender = TunDataInbound::new(tun_inbound, allow_subnet.clone(), mode);
|
||||
let inbound = if mode == DeviceMode::Tap {
|
||||
EnhancedTunInbound::Tap(tun_data_sender)
|
||||
} else {
|
||||
EnhancedTunInbound::Tun(tun_data_sender)
|
||||
};
|
||||
(inbound, Some(tun_receiver))
|
||||
}
|
||||
};
|
||||
|
||||
let (enhanced_inbound, enhanced_outbound) = enhanced_ipv4_tunnel(
|
||||
@@ -150,6 +188,7 @@ impl NetworkManager {
|
||||
password: config.password.clone(),
|
||||
open_quic_client: config.rtx,
|
||||
port_mapping: config.port_mapping.clone(),
|
||||
device_mode: config.device_mode,
|
||||
},
|
||||
crate::enhanced_tunnel::TunnelComponents {
|
||||
hybrid_outbound: hybrid_outbound.clone(),
|
||||
@@ -200,35 +239,44 @@ impl NetworkManager {
|
||||
}
|
||||
|
||||
/// Register with server(s) and start data handling tasks.
|
||||
/// This method can only be called once.
|
||||
/// Returns the registration response on success.
|
||||
pub async fn register(&mut self) -> anyhow::Result<NetworkAddr> {
|
||||
/// On connection-level failure the internal state is kept, so the call can be retried.
|
||||
pub async fn register(&mut self) -> anyhow::Result<RegisterResponse> {
|
||||
let Some(mut ctx) = self.registration_context.take() else {
|
||||
bail!("register can only be called once");
|
||||
};
|
||||
match Self::register_impl(&self.app_state, &self.task_group, &mut ctx).await {
|
||||
Ok(response) => Ok(response),
|
||||
Err(e) => {
|
||||
// 注册失败时归还上下文,允许调用方重试
|
||||
self.registration_context = Some(ctx);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn register_impl(
|
||||
app_state: &AppState,
|
||||
task_group: &TaskGroup,
|
||||
ctx: &mut RegistrationContext,
|
||||
) -> anyhow::Result<RegisterResponse> {
|
||||
let is_multi_server = ctx.server_managers.len() > 1;
|
||||
|
||||
let reg_response = if is_multi_server {
|
||||
let response = if is_multi_server {
|
||||
// Multi-server: coordinated pre-registration
|
||||
log::info!(
|
||||
"Multi-server mode: performing coordinated registration for {} servers",
|
||||
ctx.server_managers.len()
|
||||
);
|
||||
let reg_response = coordinated_registration(&mut ctx.server_managers).await?;
|
||||
log::info!(
|
||||
"Coordinated registration completed, IP: {}, prefix_len: {}",
|
||||
reg_response.ip,
|
||||
reg_response.prefix_len
|
||||
);
|
||||
reg_response
|
||||
coordinated_registration(&mut ctx.server_managers).await?
|
||||
} else {
|
||||
// Single-server: normal registration
|
||||
log::info!("Single-server mode: performing normal registration");
|
||||
let response = ctx.server_managers[0]
|
||||
ctx.server_managers[0]
|
||||
.connect_and_reg(crate::protocol::control_message::RegistrationMode::Normal)
|
||||
.await?;
|
||||
match response {
|
||||
.await?
|
||||
};
|
||||
let reg_response = match response {
|
||||
crate::protocol::control_message::ResponseMessage::Reg(reg) => {
|
||||
log::info!(
|
||||
"Registration completed, IP: {}, prefix_len: {}",
|
||||
@@ -238,12 +286,11 @@ impl NetworkManager {
|
||||
reg
|
||||
}
|
||||
crate::protocol::control_message::ResponseMessage::Error(e) => {
|
||||
bail!("Registration failed: {}", e.message);
|
||||
return Ok(RegisterResponse::Failed(e));
|
||||
}
|
||||
crate::protocol::control_message::ResponseMessage::ConfirmReg(_) => {
|
||||
bail!("Unexpected ConfirmReg response");
|
||||
}
|
||||
}
|
||||
};
|
||||
let network_addr = NetworkAddr {
|
||||
gateway: reg_response.gateway,
|
||||
@@ -251,70 +298,82 @@ impl NetworkManager {
|
||||
ip: reg_response.ip,
|
||||
prefix_len: reg_response.prefix_len,
|
||||
};
|
||||
self.app_state.network.set(network_addr);
|
||||
app_state.network.set(network_addr);
|
||||
|
||||
// 保存服务器版本信息
|
||||
if !reg_response.server_version.is_empty() {
|
||||
for (index, _) in ctx.server_managers.iter().enumerate() {
|
||||
self.app_state.server_info_collection.set_server_version(
|
||||
index as u32,
|
||||
reg_response.server_version.clone(),
|
||||
);
|
||||
app_state
|
||||
.server_info_collection
|
||||
.set_server_version(index as u32, reg_response.server_version.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Start data handling tasks for all servers
|
||||
for turn_manager in ctx.server_managers {
|
||||
for turn_manager in ctx.server_managers.drain(..) {
|
||||
let handler_config = Box::new(InboundHandlerConfig {
|
||||
network_route: NetworkRoute::new(
|
||||
self.app_state.network.clone(),
|
||||
app_state.network.clone(),
|
||||
ctx.subnet_external_route.clone(),
|
||||
),
|
||||
server_info: self.app_state.server_info_collection.clone(),
|
||||
nat_info: self.app_state.nat_info.clone(),
|
||||
peer_map: self.app_state.peer_map.clone(),
|
||||
punch_backoff: self.app_state.punch_backoff.clone(),
|
||||
server_info: app_state.server_info_collection.clone(),
|
||||
nat_info: app_state.nat_info.clone(),
|
||||
peer_map: app_state.peer_map.clone(),
|
||||
punch_backoff: app_state.punch_backoff.clone(),
|
||||
puncher: ctx.puncher.clone(),
|
||||
packet_crypto: ctx.packet_crypto.clone(),
|
||||
packet_compression: ctx.packet_compression.clone(),
|
||||
enhanced_inbound: ctx.enhanced_inbound.clone(),
|
||||
fec_decoder: ctx.fec_decoder.clone(),
|
||||
});
|
||||
turn_manager.data_handle_task_connected(&self.task_group, handler_config, network_addr);
|
||||
turn_manager.data_handle_task_connected(task_group, handler_config, network_addr);
|
||||
}
|
||||
|
||||
Ok(network_addr)
|
||||
Ok(RegisterResponse::Success(network_addr))
|
||||
}
|
||||
|
||||
pub fn is_no_tun(&self) -> bool {
|
||||
self.config.no_tun
|
||||
pub fn device_mode(&self) -> DeviceMode {
|
||||
self.config.device_mode
|
||||
}
|
||||
|
||||
pub async fn start_tun(&mut self) -> anyhow::Result<()> {
|
||||
let Some(receiver) = self.tun_receiver.take() else {
|
||||
bail!("start_tun can only be called once");
|
||||
};
|
||||
let Some(enhanced_outbound) = self.enhanced_outbound.take() else {
|
||||
bail!("start_tun can only be called once");
|
||||
};
|
||||
pub async fn start_device(&mut self) -> anyhow::Result<()> {
|
||||
if self.tun_receiver.is_none() || self.enhanced_outbound.is_none() {
|
||||
bail!("start_device requires tun/tap mode and can only be called once");
|
||||
}
|
||||
let mut config = DeviceConfig::default();
|
||||
config = config.set_mtu(self.config.mtu.unwrap_or(DEFAULT_MTU));
|
||||
config = config
|
||||
.set_device_mode(self.config.device_mode)
|
||||
.set_mtu(self.config.mtu.unwrap_or(DEFAULT_MTU));
|
||||
if self.config.device_mode == DeviceMode::Tap {
|
||||
let net = self
|
||||
.app_state
|
||||
.get_network()
|
||||
.context("network is not registered")?;
|
||||
config = config.set_mac_addr(crate::ethernet::mac_from_ip(net.ip));
|
||||
}
|
||||
if let Some(tun_name) = self.config.tun_name.clone() {
|
||||
config = config.set_tun_name(tun_name);
|
||||
}
|
||||
// 失败时 tun_receiver/enhanced_outbound 不会被消耗,可以重试
|
||||
self.device_io_manager
|
||||
.start_task(config, receiver, enhanced_outbound)
|
||||
.start_task(config, &mut self.tun_receiver, &mut self.enhanced_outbound)
|
||||
.await
|
||||
}
|
||||
#[cfg(unix)]
|
||||
pub async fn start_tun_fd(&mut self, tun_fd: Option<i32>) -> anyhow::Result<()> {
|
||||
let Some(receiver) = self.tun_receiver.take() else {
|
||||
bail!("start_tun_fd can only be called once");
|
||||
};
|
||||
let Some(enhanced_outbound) = self.enhanced_outbound.take() else {
|
||||
bail!("start_tun_fd can only be called once");
|
||||
};
|
||||
let mut config = DeviceConfig::default();
|
||||
pub async fn start_device_fd(&mut self, tun_fd: Option<i32>) -> anyhow::Result<()> {
|
||||
if self.tun_receiver.is_none() || self.enhanced_outbound.is_none() {
|
||||
bail!("start_device_fd requires tun/tap mode and can only be called once");
|
||||
}
|
||||
let mut config = DeviceConfig::default()
|
||||
.set_device_mode(self.config.device_mode)
|
||||
.set_mtu(self.config.mtu.unwrap_or(DEFAULT_MTU));
|
||||
if self.config.device_mode == DeviceMode::Tap {
|
||||
let net = self
|
||||
.app_state
|
||||
.get_network()
|
||||
.context("network is not registered")?;
|
||||
config = config.set_mac_addr(crate::ethernet::mac_from_ip(net.ip));
|
||||
}
|
||||
if let Some(tun_fd) = tun_fd {
|
||||
config = config.set_tun_fd(tun_fd);
|
||||
}
|
||||
@@ -322,11 +381,11 @@ impl NetworkManager {
|
||||
config = config.set_tun_name(tun_name);
|
||||
}
|
||||
self.device_io_manager
|
||||
.start_task(config, receiver, enhanced_outbound)
|
||||
.start_task(config, &mut self.tun_receiver, &mut self.enhanced_outbound)
|
||||
.await
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub async fn set_network_ip(&self, ip: Ipv4Addr, prefix_len: u8) -> anyhow::Result<()> {
|
||||
pub async fn set_device_network_ip(&self, ip: Ipv4Addr, prefix_len: u8) -> anyhow::Result<()> {
|
||||
self.device_io_manager.set_network(ip, prefix_len).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -336,8 +395,8 @@ impl NetworkManager {
|
||||
self.app_state.stop_network();
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub async fn tun_if_index(&self) -> anyhow::Result<u32> {
|
||||
self.device_io_manager.tun_if_index().await
|
||||
pub async fn device_if_index(&self) -> anyhow::Result<u32> {
|
||||
self.device_io_manager.device_if_index().await
|
||||
}
|
||||
pub async fn wait_all_stopped(&mut self) {
|
||||
self.task_group.wait_all_stopped().await;
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
use crate::protocol::ip_packet_protocol::{HEAD_LENGTH, NetPacket};
|
||||
use ring::aead::{Aad, CHACHA20_POLY1305, LessSafeKey, Nonce, UnboundKey};
|
||||
use std::io;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
pub const TAG_LEN: usize = 16;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PacketCrypto {
|
||||
key: LessSafeKey,
|
||||
/// 出站包序号,用于构造唯一 nonce。Clone 共享同一计数器。
|
||||
/// 随机起始值可避免进程重启后(相同密钥)复用低序号段的 nonce。
|
||||
seq: Arc<AtomicU32>,
|
||||
}
|
||||
|
||||
impl PacketCrypto {
|
||||
@@ -31,7 +36,10 @@ impl PacketCrypto {
|
||||
pub fn new(key_bytes: [u8; 32]) -> Self {
|
||||
let unbound = UnboundKey::new(&CHACHA20_POLY1305, &key_bytes).unwrap();
|
||||
let key = LessSafeKey::new(unbound);
|
||||
Self { key }
|
||||
Self {
|
||||
key,
|
||||
seq: Arc::new(AtomicU32::new(rand::random())),
|
||||
}
|
||||
}
|
||||
pub fn new_from_str(s: &str) -> Self {
|
||||
let hash = ring::digest::digest(&ring::digest::SHA256, s.as_bytes());
|
||||
@@ -39,7 +47,10 @@ impl PacketCrypto {
|
||||
key_bytes.copy_from_slice(hash.as_ref());
|
||||
Self::new(key_bytes)
|
||||
}
|
||||
/// 根据包头生成 12 字节 nonce
|
||||
/// 根据包头生成 12 字节 nonce。
|
||||
/// nonce 只承担"唯一性"职责:seq(随机起始计数器)+ src + dst,
|
||||
/// 三者构成每个 (src, dst) 流内不重复的 96 位值;
|
||||
/// 头部其余字段的完整性认证由 AAD 负责,与 nonce 无关。
|
||||
pub fn make_nonce<B: AsRef<[u8]>>(&self, pkt: &NetPacket<B>) -> io::Result<[u8; 12]> {
|
||||
let buf = pkt.buffer();
|
||||
|
||||
@@ -49,7 +60,6 @@ impl PacketCrypto {
|
||||
"buffer too small",
|
||||
));
|
||||
}
|
||||
let msg_type = buf[0];
|
||||
let seq = &buf[4..8];
|
||||
let src = &buf[8..12];
|
||||
let dst = &buf[12..16];
|
||||
@@ -58,18 +68,35 @@ impl PacketCrypto {
|
||||
nonce12[0..4].copy_from_slice(seq);
|
||||
nonce12[4..8].copy_from_slice(dst);
|
||||
nonce12[8..12].copy_from_slice(src);
|
||||
nonce12[0] = msg_type;
|
||||
|
||||
Ok(nonce12)
|
||||
}
|
||||
|
||||
/// AAD 承担"认证"职责:覆盖传输中不变、但不参与 nonce 的头部字节
|
||||
/// byte0(msg_type)/byte2(flags)/byte3(reserved)。
|
||||
/// msg_type 与 flags(COMPRESSED/FEC/GATEWAY/ETHERNET)只由发送方设置、
|
||||
/// 传输中不会被修改,必须纳入认证,否则中间人可翻转造成不可检测的
|
||||
/// 丢包/语义篡改;ttl(byte1) 在中继转发时会递减,不能纳入 AAD。
|
||||
fn make_aad<B: AsRef<[u8]>>(pkt: &NetPacket<B>) -> [u8; 3] {
|
||||
let buf = pkt.buffer();
|
||||
if buf.len() < HEAD_LENGTH {
|
||||
return [0; 3];
|
||||
}
|
||||
[buf[0], buf[2], buf[3]]
|
||||
}
|
||||
|
||||
/// 原地加密(in-place)
|
||||
/// payload 后需要预留16字节用于存放 tag
|
||||
pub fn encrypt_in_place<B: AsRef<[u8]> + AsMut<[u8]>>(
|
||||
&self,
|
||||
pkt: &mut NetPacket<B>,
|
||||
) -> io::Result<()> {
|
||||
// 为每个出站包分配递增 seq,保证同一 (src, dst) 流内 nonce 不重复
|
||||
// (seq 占满 4 字节,约 43 亿个包后才回绕)
|
||||
let seq = self.seq.fetch_add(1, Ordering::Relaxed);
|
||||
pkt.set_seq(seq);
|
||||
let nonce = Nonce::assume_unique_for_key(self.make_nonce(pkt)?);
|
||||
let aad = Aad::from(Self::make_aad(pkt));
|
||||
|
||||
let payload = pkt.payload_mut();
|
||||
let payload_len = payload.len() - TAG_LEN; // 实际 payload 长度(不含 tag 预留空间)
|
||||
@@ -77,7 +104,7 @@ impl PacketCrypto {
|
||||
// 只加密实际的 payload 部分
|
||||
let tag = self
|
||||
.key
|
||||
.seal_in_place_separate_tag(nonce, Aad::empty(), &mut payload[..payload_len])
|
||||
.seal_in_place_separate_tag(nonce, aad, &mut payload[..payload_len])
|
||||
.map_err(|_| io::Error::other("encrypt failed"))?;
|
||||
|
||||
// 将 tag 写入 payload 后的预留空间
|
||||
@@ -92,12 +119,13 @@ impl PacketCrypto {
|
||||
pkt: &mut NetPacket<B>,
|
||||
) -> io::Result<usize> {
|
||||
let nonce = Nonce::assume_unique_for_key(self.make_nonce(pkt)?);
|
||||
let aad = Aad::from(Self::make_aad(pkt));
|
||||
|
||||
let payload_with_tag = pkt.payload_mut();
|
||||
|
||||
let plaintext = self
|
||||
.key
|
||||
.open_in_place(nonce, Aad::empty(), payload_with_tag)
|
||||
.open_in_place(nonce, aad, payload_with_tag)
|
||||
.map_err(|_| io::Error::other("decrypt failed"))?;
|
||||
Ok(plaintext.len())
|
||||
}
|
||||
@@ -106,6 +134,7 @@ impl PacketCrypto {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::protocol::ip_packet_protocol::MsgType;
|
||||
use bytes::BytesMut;
|
||||
|
||||
// 用于构造一个简单的 NetPacket,包含头 16 字节 + payload + 16 字节 TAG 预留
|
||||
@@ -166,4 +195,143 @@ mod tests {
|
||||
// 解密后与原文一致
|
||||
assert_eq!(decrypted_payload, &original_payload[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nonce_unique_per_packet() {
|
||||
let crypto = PacketCrypto::new([7u8; 32]);
|
||||
|
||||
let mut pkt1 = build_test_packet(20);
|
||||
let mut pkt2 = build_test_packet(20);
|
||||
|
||||
let nonce1 = crypto.make_nonce(&pkt1).unwrap();
|
||||
crypto.encrypt_in_place(&mut pkt1).expect("encrypt failed");
|
||||
crypto.encrypt_in_place(&mut pkt2).expect("encrypt failed");
|
||||
let nonce2 = crypto.make_nonce(&pkt1).unwrap();
|
||||
let nonce3 = crypto.make_nonce(&pkt2).unwrap();
|
||||
|
||||
// 加密会自动分配递增 seq,两个相同头部的包 nonce 必须不同
|
||||
assert_eq!(pkt1.seq() + 1, pkt2.seq());
|
||||
assert_ne!(nonce1, nonce2);
|
||||
assert_ne!(nonce2, nonce3);
|
||||
// 密文也必须不同(相同明文、不同 nonce)
|
||||
assert_ne!(pkt1.buffer(), pkt2.buffer());
|
||||
|
||||
// 两个包都能正常解密(头部 seq 不同,只比较 payload 区域)
|
||||
crypto.decrypt_in_place(&mut pkt1).expect("decrypt failed");
|
||||
crypto.decrypt_in_place(&mut pkt2).expect("decrypt failed");
|
||||
assert_eq!(
|
||||
&pkt1.buffer()[HEAD_LENGTH..HEAD_LENGTH + 20],
|
||||
&pkt2.buffer()[HEAD_LENGTH..HEAD_LENGTH + 20]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_shares_seq_counter() {
|
||||
let crypto = PacketCrypto::new([9u8; 32]);
|
||||
let cloned = crypto.clone();
|
||||
|
||||
let mut pkt1 = build_test_packet(8);
|
||||
let mut pkt2 = build_test_packet(8);
|
||||
crypto.encrypt_in_place(&mut pkt1).expect("encrypt failed");
|
||||
cloned.encrypt_in_place(&mut pkt2).expect("encrypt failed");
|
||||
|
||||
assert_eq!(pkt1.seq() + 1, pkt2.seq());
|
||||
}
|
||||
|
||||
/// nonce 与 AAD 完全由包自带的头部字节推导,与发送端状态无关:
|
||||
/// 即使对端用自己的 seq 状态发包,本端仅凭头部即可正确解密。
|
||||
#[test]
|
||||
fn test_cross_version_compat() {
|
||||
let key = [7u8; 32];
|
||||
let crypto = PacketCrypto::new(key);
|
||||
// 用相同密钥的另一个实例模拟对端
|
||||
let peer = PacketCrypto::new(key);
|
||||
|
||||
// 模拟旧版本发包:seq 固定为 0,nonce 直接由头部计算
|
||||
let mut pkt = build_test_packet(20);
|
||||
let original: Vec<u8> = pkt.buffer()[HEAD_LENGTH..HEAD_LENGTH + 20].to_vec();
|
||||
pkt.set_seq(0);
|
||||
let nonce = Nonce::assume_unique_for_key(peer.make_nonce(&pkt).unwrap());
|
||||
let aad = Aad::from(PacketCrypto::make_aad(&pkt));
|
||||
let payload = pkt.payload_mut();
|
||||
let payload_len = payload.len() - TAG_LEN;
|
||||
let tag = peer
|
||||
.key
|
||||
.seal_in_place_separate_tag(nonce, aad, &mut payload[..payload_len])
|
||||
.unwrap();
|
||||
payload[payload_len..payload_len + TAG_LEN].copy_from_slice(tag.as_ref());
|
||||
|
||||
// 新版本解密旧版本的包
|
||||
crypto.decrypt_in_place(&mut pkt).expect("decrypt failed");
|
||||
assert_eq!(&pkt.buffer()[HEAD_LENGTH..HEAD_LENGTH + 20], &original[..]);
|
||||
|
||||
// 反向:新版本发(自动分配 seq),旧版本逻辑解密(nonce 只读头部)
|
||||
let mut pkt2 = build_test_packet(20);
|
||||
let original2: Vec<u8> = pkt2.buffer()[HEAD_LENGTH..HEAD_LENGTH + 20].to_vec();
|
||||
crypto.encrypt_in_place(&mut pkt2).expect("encrypt failed");
|
||||
assert_ne!(pkt2.seq(), 0, "sanity check: new version assigns seq");
|
||||
peer.decrypt_in_place(&mut pkt2).expect("decrypt failed");
|
||||
assert_eq!(
|
||||
&pkt2.buffer()[HEAD_LENGTH..HEAD_LENGTH + 20],
|
||||
&original2[..]
|
||||
);
|
||||
}
|
||||
|
||||
/// AAD 覆盖 flags(byte2):中间人翻转 COMPRESSED/FEC/GATEWAY 标志位
|
||||
/// 必须导致解密失败,而不是被静默接受。
|
||||
#[test]
|
||||
fn test_tampered_flags_rejected() {
|
||||
let crypto = PacketCrypto::new([7u8; 32]);
|
||||
|
||||
let mut pkt = build_test_packet(20);
|
||||
crypto.encrypt_in_place(&mut pkt).expect("encrypt failed");
|
||||
|
||||
// 翻转 flags 字节(模拟中间人篡改)
|
||||
pkt.set_fec_flag(true);
|
||||
|
||||
assert!(
|
||||
crypto.decrypt_in_place(&mut pkt).is_err(),
|
||||
"tampered flags must fail authentication"
|
||||
);
|
||||
}
|
||||
|
||||
/// AAD 覆盖 msg_type(byte0):中间人篡改消息类型必须导致解密失败。
|
||||
#[test]
|
||||
fn test_tampered_msg_type_rejected() {
|
||||
let crypto = PacketCrypto::new([7u8; 32]);
|
||||
|
||||
let mut pkt = build_test_packet(20);
|
||||
crypto.encrypt_in_place(&mut pkt).expect("encrypt failed");
|
||||
|
||||
pkt.set_msg_type(MsgType::Pong);
|
||||
|
||||
assert!(
|
||||
crypto.decrypt_in_place(&mut pkt).is_err(),
|
||||
"tampered msg_type must fail authentication"
|
||||
);
|
||||
}
|
||||
|
||||
/// ttl(byte1) 在中继转发时会递减,不属于 AAD:
|
||||
/// 转发后 ttl 变化的包必须仍能正常解密。
|
||||
#[test]
|
||||
fn test_ttl_change_still_decrypts() {
|
||||
let crypto = PacketCrypto::new([7u8; 32]);
|
||||
|
||||
let payload_len = 20;
|
||||
let mut pkt = build_test_packet(payload_len);
|
||||
pkt.set_ttl(15); // 初始 ttl
|
||||
let original: Vec<u8> = pkt.buffer()[HEAD_LENGTH..HEAD_LENGTH + payload_len].to_vec();
|
||||
crypto.encrypt_in_place(&mut pkt).expect("encrypt failed");
|
||||
|
||||
// 模拟中继递减 ttl
|
||||
pkt.set_ttl(14);
|
||||
|
||||
crypto
|
||||
.decrypt_in_place(&mut pkt)
|
||||
.expect("ttl change must not break decryption");
|
||||
assert_eq!(
|
||||
&pkt.buffer()[HEAD_LENGTH..HEAD_LENGTH + payload_len],
|
||||
&original[..]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,9 @@ impl PacketCrypto {
|
||||
|
||||
pub(crate) fn new_from_str(s: Option<&str>) -> Self {
|
||||
Self {
|
||||
crypto: s.map(chacha20_poly1305::PacketCrypto::new_from_str).map(Arc::new),
|
||||
crypto: s
|
||||
.map(chacha20_poly1305::PacketCrypto::new_from_str)
|
||||
.map(Arc::new),
|
||||
}
|
||||
}
|
||||
pub(crate) fn encrypt_reserve(&self) -> usize {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
use crate::context::config::DeviceMode;
|
||||
use crate::context::{NetworkAddr, TrafficStats};
|
||||
use crate::enhanced_tunnel::quic_over::quic_inbound::EnhancedQuicInbound;
|
||||
use crate::ethernet::{ETHERTYPE_IPV4, build_arp_reply, parse_arp_ipv4, parse_frame};
|
||||
use crate::nat::internal_nat::InternalNatInbound;
|
||||
use crate::protocol::ip_packet_protocol::{HEAD_LENGTH, MsgType, NetPacket};
|
||||
use crate::protocol::transmission::TransmissionBytes;
|
||||
use crate::tun::enhanced_tun::EnhancedTunInbound;
|
||||
use crate::tunnel_core::outbound::HybridOutbound;
|
||||
use anyhow::{Context, bail};
|
||||
use pnet_packet::ipv4::Ipv4Packet;
|
||||
use std::net::Ipv4Addr;
|
||||
@@ -14,6 +17,8 @@ pub(crate) struct EnhancedInbound {
|
||||
quic_inbound: EnhancedQuicInbound,
|
||||
internal_nat_inbound: Option<InternalNatInbound>,
|
||||
traffic_stats: TrafficStats,
|
||||
device_mode: DeviceMode,
|
||||
hybrid_outbound: HybridOutbound,
|
||||
}
|
||||
|
||||
impl EnhancedInbound {
|
||||
@@ -22,12 +27,16 @@ impl EnhancedInbound {
|
||||
quic_inbound: EnhancedQuicInbound,
|
||||
internal_nat_inbound: Option<InternalNatInbound>,
|
||||
traffic_stats: TrafficStats,
|
||||
device_mode: DeviceMode,
|
||||
hybrid_outbound: HybridOutbound,
|
||||
) -> Self {
|
||||
Self {
|
||||
tun_data_inbound,
|
||||
quic_inbound,
|
||||
internal_nat_inbound,
|
||||
traffic_stats,
|
||||
device_mode,
|
||||
hybrid_outbound,
|
||||
}
|
||||
}
|
||||
pub async fn inbound(
|
||||
@@ -37,26 +46,65 @@ impl EnhancedInbound {
|
||||
src: Ipv4Addr,
|
||||
packet: NetPacket<TransmissionBytes>,
|
||||
) -> anyhow::Result<()> {
|
||||
let ethernet = packet.is_ethernet();
|
||||
let mut buf = packet.into_buffer();
|
||||
self.traffic_stats.record_rx(src, buf.len() as u64);
|
||||
buf.advance_head(HEAD_LENGTH)?;
|
||||
|
||||
if ethernet && parse_frame(buf.as_ref()).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
if ethernet && self.device_mode != DeviceMode::Tap {
|
||||
if let Some(arp) = parse_arp_ipv4(buf.as_ref())
|
||||
&& arp.operation == 1
|
||||
&& arp.target_ip == network_addr.ip
|
||||
{
|
||||
if let Some(reply) = build_arp_reply(buf.as_ref(), network_addr.ip) {
|
||||
self.hybrid_outbound
|
||||
.ethernet_unicast_outbound(*network_addr, src, reply)
|
||||
.await?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if parse_frame(buf.as_ref()).is_none_or(|frame| frame.ethertype != ETHERTYPE_IPV4) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
match msg_type {
|
||||
MsgType::Turn => {
|
||||
if let Some(internal_nat_inbound) = self.internal_nat_inbound.as_ref() {
|
||||
let Some(ipv4) = Ipv4Packet::new(&buf) else {
|
||||
let ip_data = if ethernet {
|
||||
let Some(frame) = parse_frame(buf.as_ref()) else {
|
||||
return Ok(());
|
||||
};
|
||||
if frame.ethertype != ETHERTYPE_IPV4 {
|
||||
self.tun_data_inbound
|
||||
.inbound(buf, network_addr, src, true)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
&buf[frame.payload_offset..]
|
||||
} else {
|
||||
buf.as_ref()
|
||||
};
|
||||
let Some(ipv4) = Ipv4Packet::new(ip_data) else {
|
||||
bail!("EnhancedInbound not ipv4")
|
||||
};
|
||||
let dest = ipv4.get_destination();
|
||||
if dest != network_addr.ip && !network_addr.network().contains(&dest) {
|
||||
internal_nat_inbound.send(&buf, network_addr).await?;
|
||||
internal_nat_inbound.send(ip_data, network_addr).await?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
self.tun_data_inbound.inbound(buf, network_addr).await?;
|
||||
self.tun_data_inbound
|
||||
.inbound(buf, network_addr, src, ethernet)
|
||||
.await?;
|
||||
}
|
||||
MsgType::Broadcast | MsgType::ExcludeBroadcast => {
|
||||
self.tun_data_inbound.inbound(buf, network_addr).await?;
|
||||
self.tun_data_inbound
|
||||
.inbound(buf, network_addr, src, ethernet)
|
||||
.await?;
|
||||
}
|
||||
MsgType::Quic => {
|
||||
let payload = buf.into_bytes().freeze();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::context::AppState;
|
||||
use crate::context::config::DeviceMode;
|
||||
use crate::enhanced_tunnel::inbound::EnhancedInbound;
|
||||
use crate::enhanced_tunnel::outbound::EnhancedOutbound;
|
||||
use crate::nat::SubnetExternalRoute;
|
||||
@@ -18,6 +19,7 @@ pub(crate) struct TunnelConfig {
|
||||
pub password: Option<String>,
|
||||
pub open_quic_client: bool,
|
||||
pub port_mapping: Vec<PortMapping>,
|
||||
pub device_mode: DeviceMode,
|
||||
}
|
||||
|
||||
pub(crate) struct TunnelComponents {
|
||||
@@ -36,7 +38,7 @@ pub(crate) async fn enhanced_ipv4_tunnel(
|
||||
) -> anyhow::Result<(EnhancedInbound, Option<EnhancedOutbound>)> {
|
||||
let password = config.password.unwrap_or_else(|| "password".to_string());
|
||||
let tun = match &tun_data_sender {
|
||||
EnhancedTunInbound::Tun(tun) => Some(tun.clone()),
|
||||
EnhancedTunInbound::Tun(tun) | EnhancedTunInbound::Tap(tun) => Some(tun.clone()),
|
||||
EnhancedTunInbound::Nat(_) => None,
|
||||
};
|
||||
let (inbound, outbound) = quic_over::boot::quic_tunnel_start(
|
||||
@@ -62,6 +64,8 @@ pub(crate) async fn enhanced_ipv4_tunnel(
|
||||
inbound,
|
||||
components.internal_nat_inbound,
|
||||
app_state.traffic_stats.clone(),
|
||||
config.device_mode,
|
||||
components.hybrid_outbound.clone(),
|
||||
);
|
||||
|
||||
let enhanced_outbound = outbound.map(|outbound| {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
use crate::context::SharedNetworkAddr;
|
||||
use crate::enhanced_tunnel::quic_over::quic_outbound::EnhancedQuicOutbound;
|
||||
use crate::ethernet::{
|
||||
ETHERTYPE_ARP, ETHERTYPE_IPV4, build_arp_reply, ip_from_mac, is_broadcast_or_multicast,
|
||||
parse_arp_ipv4, parse_frame,
|
||||
};
|
||||
use crate::protocol::transmission::TransmissionBytes;
|
||||
use crate::tunnel_core::outbound::HybridOutbound;
|
||||
use pnet_packet::ipv4::Ipv4Packet;
|
||||
@@ -30,6 +34,74 @@ impl EnhancedOutbound {
|
||||
log::warn!("EnhancedOutbound error: {:?}", e);
|
||||
}
|
||||
}
|
||||
pub async fn ethernet_outbound(&self, data: TransmissionBytes) -> Option<TransmissionBytes> {
|
||||
match self.ethernet_outbound_impl(data).await {
|
||||
Ok(reply) => reply,
|
||||
Err(e) => {
|
||||
log::warn!("EnhancedOutbound Ethernet error: {e:?}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
async fn ethernet_outbound_impl(
|
||||
&self,
|
||||
data: TransmissionBytes,
|
||||
) -> anyhow::Result<Option<TransmissionBytes>> {
|
||||
let Some(frame) = parse_frame(data.as_ref()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(net) = self.network.get() else {
|
||||
return Ok(None);
|
||||
};
|
||||
match frame.ethertype {
|
||||
ETHERTYPE_IPV4 => {
|
||||
let Some(ipv4) = Ipv4Packet::new(&data[frame.payload_offset..]) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let src = ipv4.get_source();
|
||||
let dest = ipv4.get_destination();
|
||||
if dest == src || dest.is_unspecified() {
|
||||
return Ok(None);
|
||||
}
|
||||
self.hybrid_outbound
|
||||
.ethernet_ipv4_outbound(net, data, dest)
|
||||
.await?;
|
||||
}
|
||||
ETHERTYPE_ARP => {
|
||||
let Some(arp) = parse_arp_ipv4(data.as_ref()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if arp.operation == 1 && arp.target_ip == net.gateway {
|
||||
return Ok(build_arp_reply(data.as_ref(), net.gateway));
|
||||
}
|
||||
if arp.operation == 2 {
|
||||
let dest = ip_from_mac(frame.destination).unwrap_or(arp.target_ip);
|
||||
self.hybrid_outbound
|
||||
.ethernet_unicast_outbound(net, dest, data)
|
||||
.await?;
|
||||
} else {
|
||||
self.hybrid_outbound
|
||||
.ethernet_broadcast_outbound(net, data)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if !is_broadcast_or_multicast(frame.destination)
|
||||
&& let Some(dest) = ip_from_mac(frame.destination)
|
||||
&& net.network().contains(&dest)
|
||||
{
|
||||
self.hybrid_outbound
|
||||
.ethernet_unicast_outbound(net, dest, data)
|
||||
.await?;
|
||||
} else {
|
||||
self.hybrid_outbound
|
||||
.ethernet_broadcast_outbound(net, data)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
async fn ipv4_outbound_impl(&self, data: TransmissionBytes) -> anyhow::Result<()> {
|
||||
let Some(ipv4) = Ipv4Packet::new(data.as_ref()) else {
|
||||
return Ok(());
|
||||
|
||||
@@ -16,6 +16,7 @@ use crate::tun::TunDataInbound;
|
||||
use crate::tunnel_core::outbound::HybridOutbound;
|
||||
use crate::utils::task_control::TaskGroup;
|
||||
use anyhow::Context;
|
||||
use pnet_packet::ipv4::Ipv4Packet;
|
||||
use quinn::congestion::BbrConfig;
|
||||
use quinn::crypto::rustls::QuicServerConfig;
|
||||
use quinn::{ClientConfig, Endpoint, EndpointConfig, TransportConfig, default_runtime};
|
||||
@@ -47,10 +48,7 @@ pub(crate) async fn quic_tunnel_start(
|
||||
config: QuicTunnelConfig,
|
||||
components: QuicTunnelComponents,
|
||||
) -> anyhow::Result<(EnhancedQuicInbound, Option<EnhancedQuicOutbound>)> {
|
||||
let ip_stack_config = IpStackConfig {
|
||||
mtu: config.mtu,
|
||||
..Default::default()
|
||||
};
|
||||
let ip_stack_config = IpStackConfig::builder().mtu(config.mtu).build();
|
||||
let (ip_stack, ip_socket, quic_outbound) = if let Some(tun_data_sender) = tun_data_sender {
|
||||
let (ip_stack, ip_stack_send, ip_stack_recv) = tcp_ip::ip_stack(ip_stack_config)?;
|
||||
let ip_socket = tcp_ip::ip::IpSocket::bind_all(None, ip_stack.clone()).await?;
|
||||
@@ -189,7 +187,13 @@ async fn ip_stack_recv_task(
|
||||
log::error!("not network");
|
||||
break;
|
||||
};
|
||||
match tun_data_sender.send((&buf[..len]).into(), &net).await {
|
||||
let Some(ipv4) = Ipv4Packet::new(&buf[..len]) else {
|
||||
continue;
|
||||
};
|
||||
match tun_data_sender
|
||||
.send_ip((&buf[..len]).into(), &net, ipv4.get_source())
|
||||
.await
|
||||
{
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
log::error!("IP stack send error: {:?}", e);
|
||||
|
||||
@@ -19,10 +19,18 @@ pub struct QuicDataInbound {
|
||||
}
|
||||
impl QuicDataInbound {
|
||||
pub async fn send(&self, data: Bytes, addr: Ipv4Addr) -> anyhow::Result<()> {
|
||||
self.sender
|
||||
.send((data, addr))
|
||||
.await
|
||||
.map_err(|_e| anyhow!("quic data inbound error"))
|
||||
match self.sender.try_send((data, addr)) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {
|
||||
// 消费端处理不过来时丢包:channel 满不能阻塞整条 QUIC 接收循环,
|
||||
// 否则一个慢消费者会卡住所有对端的入站流量
|
||||
log::warn!("quic data inbound channel full, dropping packet from {addr}");
|
||||
Ok(())
|
||||
}
|
||||
Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {
|
||||
Err(anyhow!("quic data inbound error"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Debug for QuicInnerInboundReceiver {
|
||||
@@ -91,3 +99,41 @@ impl QuicInnerInboundReceiver {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
/// channel 满时 send 必须立即返回(丢包),不能阻塞接收循环
|
||||
#[tokio::test]
|
||||
async fn test_send_drops_when_channel_full() {
|
||||
let (inbound, _receiver) = create_enhanced_inbound();
|
||||
// 填满 channel(容量 256)
|
||||
for _ in 0..256 {
|
||||
inbound
|
||||
.send(Bytes::from_static(b"x"), Ipv4Addr::LOCALHOST)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
// 再发送:旧实现会永久阻塞,修复后应立即返回 Ok(丢包)
|
||||
let rs = tokio::time::timeout(
|
||||
Duration::from_millis(200),
|
||||
inbound.send(Bytes::from_static(b"y"), Ipv4Addr::LOCALHOST),
|
||||
)
|
||||
.await;
|
||||
assert!(rs.is_ok(), "send blocked on full channel");
|
||||
rs.unwrap().unwrap();
|
||||
}
|
||||
|
||||
/// channel 关闭后 send 返回错误
|
||||
#[tokio::test]
|
||||
async fn test_send_errors_when_channel_closed() {
|
||||
let (inbound, receiver) = create_enhanced_inbound();
|
||||
drop(receiver);
|
||||
let rs = inbound
|
||||
.send(Bytes::from_static(b"x"), Ipv4Addr::LOCALHOST)
|
||||
.await;
|
||||
assert!(rs.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,6 +226,26 @@ struct IpKey {
|
||||
src: Ipv4Addr,
|
||||
dest: Ipv4Addr,
|
||||
}
|
||||
|
||||
/// 按流发送任务空闲超时:超时无包则关闭 QUIC 流并回收映射条目,
|
||||
/// 避免每个 (protocol, src, dest) 三元组的流与任务永久残留
|
||||
const DEST_SENDER_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
|
||||
|
||||
/// 仅当映射仍指向同一个 channel(即仍是本任务的条目)时才移除,
|
||||
/// 避免条目被回收重建后,旧任务误删新条目
|
||||
fn remove_sender_if_same(
|
||||
map: &mut HashMap<IpKey, Sender<Bytes>>,
|
||||
key: &IpKey,
|
||||
tx: &Sender<Bytes>,
|
||||
) -> bool {
|
||||
if let Some(cur) = map.get(key)
|
||||
&& cur.same_channel(tx)
|
||||
{
|
||||
map.remove(key);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
async fn ip_listen_impl(
|
||||
task_group: TaskGroup,
|
||||
ip_socket: Arc<IpSocket>,
|
||||
@@ -253,7 +273,14 @@ async fn ip_listen_impl(
|
||||
} else {
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<Bytes>(128);
|
||||
|
||||
spawn_dest_sender(task_group.clone(), key, rx, quic_tunnel_client.clone());
|
||||
spawn_dest_sender(
|
||||
task_group.clone(),
|
||||
key,
|
||||
tx.clone(),
|
||||
rx,
|
||||
dest_map.clone(),
|
||||
quic_tunnel_client.clone(),
|
||||
);
|
||||
|
||||
map.insert(key, tx.clone());
|
||||
tx
|
||||
@@ -275,7 +302,9 @@ async fn ip_listen_impl(
|
||||
fn spawn_dest_sender(
|
||||
task_group: TaskGroup,
|
||||
key: IpKey,
|
||||
tx: Sender<Bytes>,
|
||||
mut rx: tokio::sync::mpsc::Receiver<Bytes>,
|
||||
dest_map: Arc<Mutex<HashMap<IpKey, Sender<Bytes>>>>,
|
||||
quic_tunnel_client: QuicTunnelClient,
|
||||
) {
|
||||
log::info!("send ip({}) packet {}->{}", key.protocol, key.src, key.dest);
|
||||
@@ -294,9 +323,19 @@ fn spawn_dest_sender(
|
||||
|
||||
let mut framed = FramedWrite::new(send_stream, LengthDelimitedCodec::new());
|
||||
|
||||
while let Some(pkt) = rx.recv().await {
|
||||
loop {
|
||||
// 空闲超时后退出,回收流与映射条目
|
||||
match tokio::time::timeout(DEST_SENDER_IDLE_TIMEOUT, rx.recv()).await {
|
||||
Ok(Some(pkt)) => {
|
||||
framed.send(pkt).await?;
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(_) => {
|
||||
log::debug!("key {:?} sender idle timeout, closing stream", key);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok::<(), anyhow::Error>(())
|
||||
}
|
||||
@@ -305,5 +344,43 @@ fn spawn_dest_sender(
|
||||
if let Err(e) = result {
|
||||
log::error!("key {:?} sender task exit: {:?}", key, e);
|
||||
}
|
||||
|
||||
// 回收映射条目(仅当仍是本任务的 channel)
|
||||
remove_sender_if_same(&mut dest_map.lock(), &key, &tx);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_key() -> IpKey {
|
||||
IpKey {
|
||||
protocol: pnet_packet::ip::IpNextHeaderProtocols::Udp,
|
||||
src: Ipv4Addr::new(10, 0, 0, 1),
|
||||
dest: Ipv4Addr::new(10, 0, 0, 2),
|
||||
}
|
||||
}
|
||||
|
||||
/// 空闲回收竞态:条目被重建后,旧任务退出不得误删新条目
|
||||
#[test]
|
||||
fn test_remove_sender_if_same() {
|
||||
let key = test_key();
|
||||
let mut map = HashMap::new();
|
||||
let (old_tx, _old_rx) = tokio::sync::mpsc::channel::<Bytes>(1);
|
||||
map.insert(key, old_tx.clone());
|
||||
|
||||
// 条目被重建为新 channel
|
||||
let (new_tx, _new_rx) = tokio::sync::mpsc::channel::<Bytes>(1);
|
||||
map.insert(key, new_tx);
|
||||
|
||||
// 旧任务退出:不允许删除新条目
|
||||
assert!(!remove_sender_if_same(&mut map, &key, &old_tx));
|
||||
assert!(map.contains_key(&key));
|
||||
|
||||
// 新任务退出:允许删除
|
||||
let new_tx = map.get(&key).unwrap().clone();
|
||||
assert!(remove_sender_if_same(&mut map, &key, &new_tx));
|
||||
assert!(!map.contains_key(&key));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,9 @@ impl EnhancedQuicOutbound {
|
||||
let segmented = more_fragments || offset > 0;
|
||||
if !segmented {
|
||||
let Some(tcp) = TcpPacket::new(ipv4.payload()) else {
|
||||
return true;
|
||||
// TCP 解析失败无法判断连接归属,返回 false 让其他
|
||||
// 转发路径处理,不能返回 true 静默吞掉这个包
|
||||
return false;
|
||||
};
|
||||
// 不是第一个包
|
||||
if !(tcp.get_flags() & SYN == SYN && tcp.get_flags() & ACK != ACK) {
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
use crate::context::NetworkAddr;
|
||||
use crate::protocol::ip_packet_protocol::HEAD_LENGTH;
|
||||
use crate::protocol::transmission::TransmissionBytes;
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
pub const ETHERNET_HEADER_LEN: usize = 14;
|
||||
pub const ETHERTYPE_IPV4: u16 = 0x0800;
|
||||
pub const ETHERTYPE_ARP: u16 = 0x0806;
|
||||
const ETHERTYPE_VLAN: u16 = 0x8100;
|
||||
const ETHERTYPE_QINQ: u16 = 0x88a8;
|
||||
const ETHERTYPE_VLAN_9100: u16 = 0x9100;
|
||||
const ARP_IPV4_LEN: usize = 28;
|
||||
|
||||
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
|
||||
pub struct FrameInfo {
|
||||
pub destination: [u8; 6],
|
||||
pub source: [u8; 6],
|
||||
pub ethertype: u16,
|
||||
pub payload_offset: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
|
||||
pub struct ArpIpv4 {
|
||||
pub operation: u16,
|
||||
pub sender_mac: [u8; 6],
|
||||
pub sender_ip: Ipv4Addr,
|
||||
pub target_mac: [u8; 6],
|
||||
pub target_ip: Ipv4Addr,
|
||||
}
|
||||
|
||||
pub fn mac_from_ip(ip: Ipv4Addr) -> [u8; 6] {
|
||||
let octets = ip.octets();
|
||||
[0x02, 0x00, octets[0], octets[1], octets[2], octets[3]]
|
||||
}
|
||||
|
||||
pub fn ip_from_mac(mac: [u8; 6]) -> Option<Ipv4Addr> {
|
||||
(mac[0] == 0x02 && mac[1] == 0x00).then(|| Ipv4Addr::new(mac[2], mac[3], mac[4], mac[5]))
|
||||
}
|
||||
|
||||
pub fn parse_frame(frame: &[u8]) -> Option<FrameInfo> {
|
||||
if frame.len() < ETHERNET_HEADER_LEN {
|
||||
return None;
|
||||
}
|
||||
let destination = frame[0..6].try_into().ok()?;
|
||||
let source = frame[6..12].try_into().ok()?;
|
||||
let mut ethertype = u16::from_be_bytes(frame[12..14].try_into().ok()?);
|
||||
let mut payload_offset = ETHERNET_HEADER_LEN;
|
||||
// Support stacked VLAN tags. Four tags is already beyond normal Q-in-Q usage
|
||||
// and bounds work performed for an untrusted frame.
|
||||
for _ in 0..4 {
|
||||
if !matches!(
|
||||
ethertype,
|
||||
ETHERTYPE_VLAN | ETHERTYPE_QINQ | ETHERTYPE_VLAN_9100
|
||||
) {
|
||||
break;
|
||||
}
|
||||
if frame.len() < payload_offset + 4 {
|
||||
return None;
|
||||
}
|
||||
ethertype = u16::from_be_bytes(
|
||||
frame[payload_offset + 2..payload_offset + 4]
|
||||
.try_into()
|
||||
.ok()?,
|
||||
);
|
||||
payload_offset += 4;
|
||||
}
|
||||
(frame.len() >= payload_offset).then_some(FrameInfo {
|
||||
destination,
|
||||
source,
|
||||
ethertype,
|
||||
payload_offset,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn parse_arp_ipv4(frame: &[u8]) -> Option<ArpIpv4> {
|
||||
let info = parse_frame(frame)?;
|
||||
if info.ethertype != ETHERTYPE_ARP || frame.len() < info.payload_offset + ARP_IPV4_LEN {
|
||||
return None;
|
||||
}
|
||||
let arp = &frame[info.payload_offset..];
|
||||
if u16::from_be_bytes(arp[0..2].try_into().ok()?) != 1
|
||||
|| u16::from_be_bytes(arp[2..4].try_into().ok()?) != ETHERTYPE_IPV4
|
||||
|| arp[4] != 6
|
||||
|| arp[5] != 4
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(ArpIpv4 {
|
||||
operation: u16::from_be_bytes(arp[6..8].try_into().ok()?),
|
||||
sender_mac: arp[8..14].try_into().ok()?,
|
||||
sender_ip: Ipv4Addr::from(<[u8; 4]>::try_from(&arp[14..18]).ok()?),
|
||||
target_mac: arp[18..24].try_into().ok()?,
|
||||
target_ip: Ipv4Addr::from(<[u8; 4]>::try_from(&arp[24..28]).ok()?),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_arp_reply(request: &[u8], own_ip: Ipv4Addr) -> Option<TransmissionBytes> {
|
||||
let info = parse_frame(request)?;
|
||||
let arp = parse_arp_ipv4(request)?;
|
||||
if arp.operation != 1 || arp.target_ip != own_ip {
|
||||
return None;
|
||||
}
|
||||
let frame_len = request.len().max(info.payload_offset + ARP_IPV4_LEN);
|
||||
let mut bytes = TransmissionBytes::with_capacity(HEAD_LENGTH, HEAD_LENGTH + frame_len);
|
||||
bytes.put(request).ok()?;
|
||||
if bytes.len() < frame_len {
|
||||
bytes.extend_end(frame_len - bytes.len());
|
||||
}
|
||||
let own_mac = mac_from_ip(own_ip);
|
||||
bytes[0..6].copy_from_slice(&arp.sender_mac);
|
||||
bytes[6..12].copy_from_slice(&own_mac);
|
||||
let payload = info.payload_offset;
|
||||
bytes[payload + 6..payload + 8].copy_from_slice(&2u16.to_be_bytes());
|
||||
bytes[payload + 8..payload + 14].copy_from_slice(&own_mac);
|
||||
bytes[payload + 14..payload + 18].copy_from_slice(&own_ip.octets());
|
||||
bytes[payload + 18..payload + 24].copy_from_slice(&arp.sender_mac);
|
||||
bytes[payload + 24..payload + 28].copy_from_slice(&arp.sender_ip.octets());
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
pub fn strip_ipv4(mut frame: TransmissionBytes) -> Option<TransmissionBytes> {
|
||||
let info = parse_frame(frame.as_ref())?;
|
||||
if info.ethertype != ETHERTYPE_IPV4 {
|
||||
return None;
|
||||
}
|
||||
frame.advance_head(info.payload_offset).ok()?;
|
||||
Some(frame)
|
||||
}
|
||||
|
||||
pub fn wrap_ipv4(
|
||||
mut packet: TransmissionBytes,
|
||||
src_node: Ipv4Addr,
|
||||
net: &NetworkAddr,
|
||||
) -> Option<TransmissionBytes> {
|
||||
let ipv4 = pnet_packet::ipv4::Ipv4Packet::new(packet.as_ref())?;
|
||||
let destination_ip = ipv4.get_destination();
|
||||
let destination_mac = if destination_ip.is_broadcast() || destination_ip == net.broadcast {
|
||||
[0xff; 6]
|
||||
} else if destination_ip.is_multicast() {
|
||||
let octets = destination_ip.octets();
|
||||
[0x01, 0x00, 0x5e, octets[1] & 0x7f, octets[2], octets[3]]
|
||||
} else {
|
||||
mac_from_ip(net.ip)
|
||||
};
|
||||
packet.retreat_head(ETHERNET_HEADER_LEN).ok()?;
|
||||
packet[0..6].copy_from_slice(&destination_mac);
|
||||
packet[6..12].copy_from_slice(&mac_from_ip(src_node));
|
||||
packet[12..14].copy_from_slice(ÐERTYPE_IPV4.to_be_bytes());
|
||||
Some(packet)
|
||||
}
|
||||
|
||||
pub fn is_broadcast_or_multicast(mac: [u8; 6]) -> bool {
|
||||
mac == [0xff; 6] || mac[0] & 1 != 0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn node_mac_round_trip() {
|
||||
let ip = Ipv4Addr::new(10, 26, 1, 9);
|
||||
assert_eq!(mac_from_ip(ip), [2, 0, 10, 26, 1, 9]);
|
||||
assert_eq!(ip_from_mac(mac_from_ip(ip)), Some(ip));
|
||||
assert_eq!(ip_from_mac([0; 6]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_vlan_ipv4() {
|
||||
let mut frame = vec![0u8; 18 + 20];
|
||||
frame[12..14].copy_from_slice(ÐERTYPE_VLAN.to_be_bytes());
|
||||
frame[16..18].copy_from_slice(ÐERTYPE_IPV4.to_be_bytes());
|
||||
let info = parse_frame(&frame).unwrap();
|
||||
assert_eq!(info.ethertype, ETHERTYPE_IPV4);
|
||||
assert_eq!(info.payload_offset, 18);
|
||||
assert!(parse_frame(&[0u8; 13]).is_none());
|
||||
let mut truncated_vlan = vec![0u8; 16];
|
||||
truncated_vlan[12..14].copy_from_slice(ÐERTYPE_VLAN.to_be_bytes());
|
||||
assert!(parse_frame(&truncated_vlan).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_arp_reply_for_node_ip() {
|
||||
let sender_ip = Ipv4Addr::new(10, 26, 0, 8);
|
||||
let target_ip = Ipv4Addr::new(10, 26, 0, 9);
|
||||
let sender_mac = mac_from_ip(sender_ip);
|
||||
let mut request = vec![0u8; 42];
|
||||
request[0..6].copy_from_slice(&[0xff; 6]);
|
||||
request[6..12].copy_from_slice(&sender_mac);
|
||||
request[12..14].copy_from_slice(ÐERTYPE_ARP.to_be_bytes());
|
||||
request[14..16].copy_from_slice(&1u16.to_be_bytes());
|
||||
request[16..18].copy_from_slice(ÐERTYPE_IPV4.to_be_bytes());
|
||||
request[18] = 6;
|
||||
request[19] = 4;
|
||||
request[20..22].copy_from_slice(&1u16.to_be_bytes());
|
||||
request[22..28].copy_from_slice(&sender_mac);
|
||||
request[28..32].copy_from_slice(&sender_ip.octets());
|
||||
request[38..42].copy_from_slice(&target_ip.octets());
|
||||
|
||||
let reply = build_arp_reply(&request, target_ip).unwrap();
|
||||
let arp = parse_arp_ipv4(reply.as_ref()).unwrap();
|
||||
assert_eq!(arp.operation, 2);
|
||||
assert_eq!(arp.sender_ip, target_ip);
|
||||
assert_eq!(arp.sender_mac, mac_from_ip(target_ip));
|
||||
assert_eq!(arp.target_ip, sender_ip);
|
||||
assert_eq!(arp.target_mac, sender_mac);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wraps_ipv4_for_tap_and_strips_it_again() {
|
||||
let net = NetworkAddr {
|
||||
gateway: Ipv4Addr::new(10, 26, 0, 1),
|
||||
broadcast: Ipv4Addr::new(10, 26, 0, 255),
|
||||
ip: Ipv4Addr::new(10, 26, 0, 9),
|
||||
prefix_len: 24,
|
||||
};
|
||||
let src = Ipv4Addr::new(10, 26, 0, 8);
|
||||
let mut packet = TransmissionBytes::with_capacity(HEAD_LENGTH, HEAD_LENGTH + 20);
|
||||
packet.put(&[0u8; 20]).unwrap();
|
||||
packet[0] = 0x45;
|
||||
packet[12..16].copy_from_slice(&src.octets());
|
||||
packet[16..20].copy_from_slice(&net.ip.octets());
|
||||
let original = packet.as_ref().to_vec();
|
||||
|
||||
let frame = wrap_ipv4(packet, src, &net).unwrap();
|
||||
let info = parse_frame(frame.as_ref()).unwrap();
|
||||
assert_eq!(info.source, mac_from_ip(src));
|
||||
assert_eq!(info.destination, mac_from_ip(net.ip));
|
||||
assert_eq!(strip_ipv4(frame).unwrap().as_ref(), original);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,8 @@ struct FecDecoderInner {
|
||||
struct FecGroup {
|
||||
data_shards: usize,
|
||||
parity_shards: usize,
|
||||
/// shard 统一长度(校验包到达后才能确定,等于编码端填充后的 max_len)
|
||||
shard_size: usize,
|
||||
received_original_count: usize,
|
||||
received_shards: Vec<Option<Vec<u8>>>,
|
||||
last_update: Instant,
|
||||
@@ -44,6 +46,7 @@ impl Default for FecGroup {
|
||||
Self {
|
||||
data_shards: 0,
|
||||
parity_shards: 0,
|
||||
shard_size: 0,
|
||||
received_original_count: 0,
|
||||
received_shards: Vec::with_capacity(16),
|
||||
last_update: Instant::now(),
|
||||
@@ -112,22 +115,58 @@ impl FecDecoder {
|
||||
);
|
||||
bail!("packet_index overflow {src_ip}");
|
||||
}
|
||||
// 校验包索引必须落在 [data_shards, data_shards+parity_shards) 区间,
|
||||
// 否则会覆盖数据区 shard,污染整个 group
|
||||
if packet_index < data_shards {
|
||||
log::warn!(
|
||||
"parity packet_index in data region, src={src_ip},group_id={group_id}, packet_index={packet_index}, data_shards={data_shards}"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
if group.data_shards != 0 && group.data_shards != data_shards {
|
||||
bail!("group data_shards!=data_shards {src_ip}");
|
||||
}
|
||||
if group.parity_shards != 0 && group.parity_shards != parity_shards {
|
||||
bail!("group parity_shards!=parity_shards {src_ip}");
|
||||
}
|
||||
// 尺寸未知时到达的越界数据包可能已把 received_shards 撑大,
|
||||
// 该 group 已无法解码,直接放弃(等超时 GC)
|
||||
if group.received_shards.len() > data_shards + parity_shards {
|
||||
log::warn!(
|
||||
"fec group polluted by out-of-range packet_index, src={src_ip},group_id={group_id}, received_shards={}, total={}",
|
||||
group.received_shards.len(),
|
||||
data_shards + parity_shards
|
||||
);
|
||||
bail!("fec group polluted {src_ip}");
|
||||
}
|
||||
group.data_shards = data_shards;
|
||||
group.parity_shards = parity_shards;
|
||||
// 校验包在线上即编码端填充后的 shard 统一长度
|
||||
group.shard_size = payload.len();
|
||||
if group.received_shards.len() < data_shards + parity_shards {
|
||||
group
|
||||
.received_shards
|
||||
.resize(data_shards + parity_shards, None);
|
||||
}
|
||||
|
||||
// RS 要求所有 shard 等长:把先到的数据 shard 补齐到 shard_size
|
||||
// (编码端发送前把所有数据 shard 填充到 max_len)
|
||||
for shard in group.received_shards[..data_shards].iter_mut().flatten() {
|
||||
if shard.len() < group.shard_size {
|
||||
shard.resize(group.shard_size, 0);
|
||||
}
|
||||
}
|
||||
group.received_shards[packet_index] = Some(payload);
|
||||
} else {
|
||||
// group 尺寸已知时,数据包索引必须落在数据区,
|
||||
// 否则会把 received_shards 撑出 data_shards+parity_shards,污染整个 group
|
||||
if group.data_shards != 0 && packet_index >= group.data_shards {
|
||||
log::warn!(
|
||||
"data packet_index overflow, src={src_ip},group_id={group_id}, packet_index={packet_index}, data_shards={}",
|
||||
group.data_shards
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
let buffer = TransmissionBytes::zeroed(HEAD_LENGTH + payload.len());
|
||||
let mut result_packet = NetPacket::new(buffer)?;
|
||||
result_packet.head_mut().copy_from_slice(net_packet.head());
|
||||
@@ -142,11 +181,19 @@ impl FecDecoder {
|
||||
// 保存FEC数据: [type_byte, flags_byte, payload_len(u16), payload...]
|
||||
let type_byte = net_packet.head()[0];
|
||||
let flags_byte = net_packet.head()[2];
|
||||
// 长度字段为 u16,超限必须拒绝而非静默截断(实际 payload 远小于此值,
|
||||
// 这里是防御性检查)
|
||||
let payload_len = u16::try_from(payload.len())
|
||||
.map_err(|_| anyhow::anyhow!("fec payload too large: {}", payload.len()))?;
|
||||
let mut batch_data = vec![0u8; 4 + payload.len()];
|
||||
batch_data[0] = type_byte;
|
||||
batch_data[1] = flags_byte;
|
||||
batch_data[2..4].copy_from_slice(&(payload.len() as u16).to_be_bytes());
|
||||
batch_data[2..4].copy_from_slice(&payload_len.to_be_bytes());
|
||||
batch_data[4..].copy_from_slice(&payload);
|
||||
// 校验包先到时 shard 尺寸已知,补齐保持所有 shard 等长
|
||||
if group.shard_size != 0 && batch_data.len() < group.shard_size {
|
||||
batch_data.resize(group.shard_size, 0);
|
||||
}
|
||||
group.received_shards[packet_index] = Some(batch_data);
|
||||
group.received_original_count += 1;
|
||||
}
|
||||
@@ -161,7 +208,26 @@ impl FecDecoder {
|
||||
return Ok(packet.map(|v| vec![v]));
|
||||
}
|
||||
|
||||
let result = Self::try_decode(group, (src_ip, group_id), &net_packet)?;
|
||||
// 解码失败(如对端构造的异常 group)只放弃该 group,
|
||||
// 不能向上传播错误——否则当前完好接收的包会被连带丢弃
|
||||
let decode_failed;
|
||||
let result = match Self::try_decode(group, (src_ip, group_id), &net_packet) {
|
||||
Ok(result) => {
|
||||
decode_failed = false;
|
||||
result
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"fec decode failed, drop group, src={src_ip},group_id={group_id}: {e:?}"
|
||||
);
|
||||
decode_failed = true;
|
||||
None
|
||||
}
|
||||
};
|
||||
if decode_failed {
|
||||
// 移除坏 group,后续该 group 的完好数据包按新 group 正常透传
|
||||
inner.groups.remove(&(src_ip, group_id));
|
||||
}
|
||||
|
||||
if inner.last_cleanup.elapsed() > Duration::from_secs(1) {
|
||||
Self::cleanup_old_groups(&mut inner.groups);
|
||||
@@ -300,3 +366,260 @@ impl FecDecoder {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::fec::encoder::ParityData;
|
||||
|
||||
const SRC: u32 = 0x0A000001;
|
||||
const DST: u32 = 0x0A000002;
|
||||
|
||||
/// 构造线上的 FEC 数据包(payload 为 prost 编码的 FecPacket)
|
||||
fn build_data_packet(
|
||||
group_id: u64,
|
||||
packet_index: u32,
|
||||
type_byte: u8,
|
||||
flags_byte: u8,
|
||||
payload: &[u8],
|
||||
) -> NetPacket<TransmissionBytes> {
|
||||
let fec = FecPacket {
|
||||
group_id,
|
||||
packet_index,
|
||||
payload: payload.to_vec(),
|
||||
parity_data: None,
|
||||
};
|
||||
build_packet(fec, type_byte, flags_byte)
|
||||
}
|
||||
|
||||
fn build_parity_packet(
|
||||
group_id: u64,
|
||||
packet_index: u32,
|
||||
payload: Vec<u8>,
|
||||
data_shards: u32,
|
||||
parity_shards: u32,
|
||||
) -> NetPacket<TransmissionBytes> {
|
||||
let fec = FecPacket {
|
||||
group_id,
|
||||
packet_index,
|
||||
payload,
|
||||
parity_data: Some(ParityData {
|
||||
data_shards,
|
||||
parity_shards,
|
||||
}),
|
||||
};
|
||||
// 校验包头部的 type/flags 不参与数据重建,固定取值
|
||||
build_packet(fec, 0x81, 0)
|
||||
}
|
||||
|
||||
fn build_packet(fec: FecPacket, type_byte: u8, flags_byte: u8) -> NetPacket<TransmissionBytes> {
|
||||
let fec_payload = fec.encode_to_vec();
|
||||
let buffer = TransmissionBytes::zeroed(HEAD_LENGTH + fec_payload.len());
|
||||
let mut pkt = NetPacket::new(buffer).unwrap();
|
||||
pkt.head_mut()[0] = type_byte;
|
||||
pkt.head_mut()[2] = flags_byte;
|
||||
pkt.set_src_id(SRC);
|
||||
pkt.set_dest_id(DST);
|
||||
pkt.set_ttl(5);
|
||||
pkt.set_fec_flag(true);
|
||||
pkt.set_payload(&fec_payload).unwrap();
|
||||
pkt
|
||||
}
|
||||
|
||||
/// 按编码端格式组装 shard:[type_byte, flags_byte, len(u16), payload...],填充到 max_len
|
||||
fn make_shard(type_byte: u8, flags_byte: u8, payload: &[u8], max_len: usize) -> Vec<u8> {
|
||||
let mut shard = vec![0u8; max_len];
|
||||
shard[0] = type_byte;
|
||||
shard[1] = flags_byte;
|
||||
shard[2..4].copy_from_slice(&(payload.len() as u16).to_be_bytes());
|
||||
shard[4..4 + payload.len()].copy_from_slice(payload);
|
||||
shard
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_original_fec_packet_preserves_ethernet_flag() {
|
||||
let decoder = FecDecoder::new();
|
||||
let packets = decoder
|
||||
.receive(build_data_packet(99, 0, 0x81, 0x10, &[1, 2, 3]))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(packets.len(), 1);
|
||||
assert!(packets[0].is_ethernet());
|
||||
assert!(!packets[0].is_fec());
|
||||
}
|
||||
|
||||
/// 变长批次:丢一个数据包,靠校验包必须能恢复(修复前必报 IncorrectShardSize)
|
||||
#[test]
|
||||
fn test_reconstruct_variable_length_batch() {
|
||||
let decoder = FecDecoder::new();
|
||||
let group_id = 1u64;
|
||||
|
||||
let payloads: [&[u8]; 3] = [&[0xAA; 10], &[0xBB; 30], &[0xCC; 20]];
|
||||
let type_bytes = [0x81u8, 0x82, 0x83];
|
||||
let max_len = 4 + 30; // 编码端按最长 shard 填充
|
||||
|
||||
// 编码端:3 数据 + 1 校验
|
||||
let mut shards: Vec<Vec<u8>> = payloads
|
||||
.iter()
|
||||
.zip(type_bytes)
|
||||
.map(|(p, t)| make_shard(t, 0, p, max_len))
|
||||
.collect();
|
||||
shards.push(vec![0u8; max_len]);
|
||||
let rs = ReedSolomon::new(3, 1).unwrap();
|
||||
{
|
||||
let mut refs: Vec<&mut [u8]> = shards.iter_mut().map(|s| s.as_mut()).collect();
|
||||
rs.encode(&mut refs).unwrap();
|
||||
}
|
||||
let parity = shards[3].clone();
|
||||
|
||||
// 收到 pkt0、pkt1,pkt2 丢失,随后收到校验包
|
||||
assert!(
|
||||
decoder
|
||||
.receive(build_data_packet(
|
||||
group_id,
|
||||
0,
|
||||
type_bytes[0],
|
||||
0,
|
||||
payloads[0]
|
||||
))
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
decoder
|
||||
.receive(build_data_packet(
|
||||
group_id,
|
||||
1,
|
||||
type_bytes[1],
|
||||
0,
|
||||
payloads[1]
|
||||
))
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
let recovered = decoder
|
||||
.receive(build_parity_packet(group_id, 3, parity, 3, 1))
|
||||
.unwrap()
|
||||
.expect("should reconstruct the lost packet");
|
||||
|
||||
assert_eq!(recovered.len(), 1);
|
||||
assert_eq!(recovered[0].payload(), payloads[2]);
|
||||
assert_eq!(recovered[0].head()[0], type_bytes[2]);
|
||||
assert_eq!(recovered[0].src_id(), SRC);
|
||||
assert_eq!(recovered[0].dest_id(), DST);
|
||||
assert!(!recovered[0].is_fec());
|
||||
}
|
||||
|
||||
/// 校验包先到时,后到的数据 shard 也要补齐(等长)后能恢复
|
||||
#[test]
|
||||
fn test_reconstruct_when_parity_arrives_first() {
|
||||
let decoder = FecDecoder::new();
|
||||
let group_id = 2u64;
|
||||
|
||||
let payloads: [&[u8]; 2] = [&[0x11; 8], &[0x22; 24]];
|
||||
let max_len = 4 + 24;
|
||||
|
||||
let mut shards = [
|
||||
make_shard(0x81, 0, payloads[0], max_len),
|
||||
make_shard(0x82, 0, payloads[1], max_len),
|
||||
vec![0u8; max_len],
|
||||
];
|
||||
let rs = ReedSolomon::new(2, 1).unwrap();
|
||||
{
|
||||
let mut refs: Vec<&mut [u8]> = shards.iter_mut().map(|s| s.as_mut()).collect();
|
||||
rs.encode(&mut refs).unwrap();
|
||||
}
|
||||
let parity = shards[2].clone();
|
||||
|
||||
// 校验包先到(此时丢失 pkt0 还未知),再收到 pkt1
|
||||
assert!(
|
||||
decoder
|
||||
.receive(build_parity_packet(group_id, 2, parity, 2, 1))
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let recovered = decoder
|
||||
.receive(build_data_packet(group_id, 1, 0x82, 0, payloads[1]))
|
||||
.unwrap()
|
||||
.expect("should reconstruct pkt0 after parity-first");
|
||||
|
||||
// 校验包先到时 pkt1 会透传,恢复的 pkt0 也应在返回列表中
|
||||
let recovered_pkt0 = recovered
|
||||
.iter()
|
||||
.find(|p| p.payload() == payloads[0])
|
||||
.expect("recovered pkt0 missing");
|
||||
assert_eq!(recovered_pkt0.head()[0], 0x81);
|
||||
}
|
||||
|
||||
/// 异常 group(校验 shard 比数据 shard 短)导致重建失败时:
|
||||
/// 当前包不被连带丢弃,坏 group 被移除,后续数据包正常透传
|
||||
#[test]
|
||||
fn test_decode_failure_does_not_swallow_good_packets() {
|
||||
let decoder = FecDecoder::new();
|
||||
let group_id = 3u64;
|
||||
|
||||
// 先到一个较大的数据包
|
||||
assert!(
|
||||
decoder
|
||||
.receive(build_data_packet(group_id, 0, 0x81, 0, &[0xAA; 40]))
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
// 异常校验包:shard 只有 4 字节,比已存数据 shard 短,reconstruct 必失败
|
||||
assert!(
|
||||
decoder
|
||||
.receive(build_parity_packet(group_id, 2, vec![0u8; 4], 2, 1))
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
// 坏 group 已被移除,后续数据包按新 group 正常透传
|
||||
let passed = decoder
|
||||
.receive(build_data_packet(group_id, 1, 0x82, 0, &[0xBB; 8]))
|
||||
.unwrap()
|
||||
.expect("good packet must not be swallowed");
|
||||
assert_eq!(passed.len(), 1);
|
||||
assert_eq!(passed[0].payload(), &[0xBB; 8][..]);
|
||||
}
|
||||
|
||||
/// 越界数据包不污染 group:尺寸已知后到达的越界 packet_index 必须被丢弃,
|
||||
/// 不能把 received_shards 撑出 data_shards+parity_shards 导致整组解码失败
|
||||
#[test]
|
||||
fn test_out_of_range_data_index_does_not_poison_group() {
|
||||
let decoder = FecDecoder::new();
|
||||
let group_id = 1u64;
|
||||
let shard = make_shard(0x81, 0, &[0xAA; 10], 14);
|
||||
// 校验包先到:data_shards=2, parity_shards=1,索引 2
|
||||
let parity = build_parity_packet(group_id, 2, shard, 2, 1);
|
||||
decoder.receive(parity).unwrap();
|
||||
|
||||
// 越界数据包(index 5 >= data_shards 2):必须被丢弃
|
||||
let evil = build_data_packet(group_id, 5, 0x81, 0, &[0xEE; 10]);
|
||||
assert!(decoder.receive(evil).unwrap().is_none());
|
||||
|
||||
// 合法数据包 p0:received_shards 未被撑大,正常触发重构,
|
||||
// 返回 p0 和恢复出的 p1 共 2 个包(修复前 group 已被污染,解码失败只返回 p0)
|
||||
let p0 = build_data_packet(group_id, 0, 0x81, 0, &[0xAA; 10]);
|
||||
let out = decoder.receive(p0).unwrap().expect("packet 0 delivered");
|
||||
assert_eq!(out.len(), 2, "should deliver p0 and recovered p1");
|
||||
// group 已完成,p1 重传被忽略
|
||||
let p1 = build_data_packet(group_id, 1, 0x81, 0, &[0xBB; 10]);
|
||||
assert!(decoder.receive(p1).unwrap().is_none());
|
||||
}
|
||||
|
||||
/// 落在数据区的校验包索引必须被拒绝,且不能破坏 group
|
||||
#[test]
|
||||
fn test_parity_index_in_data_region_rejected() {
|
||||
let decoder = FecDecoder::new();
|
||||
let group_id = 1u64;
|
||||
let shard = make_shard(0x81, 0, &[0xAA; 10], 14);
|
||||
// 索引 0 < data_shards=2 的"校验包":必须拒绝
|
||||
let bad_parity = build_parity_packet(group_id, 0, shard.clone(), 2, 1);
|
||||
assert!(decoder.receive(bad_parity).unwrap().is_none());
|
||||
|
||||
// group 仍可用:合法校验包 + 数据包正常处理
|
||||
let parity = build_parity_packet(group_id, 2, shard, 2, 1);
|
||||
decoder.receive(parity).unwrap();
|
||||
let p0 = build_data_packet(group_id, 0, 0x81, 0, &[0xAA; 10]);
|
||||
assert!(decoder.receive(p0).unwrap().is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ mod fec_proto {
|
||||
}
|
||||
use crate::utils::task_control::TaskGroup;
|
||||
pub use fec_proto::FecPacket;
|
||||
#[cfg(test)]
|
||||
pub use fec_proto::ParityData;
|
||||
|
||||
const BATCH_SIZE: usize = 10;
|
||||
const REDUNDANCY_RATE: f32 = 0.2;
|
||||
|
||||
@@ -2,6 +2,7 @@ pub(crate) mod compression;
|
||||
pub mod context;
|
||||
pub mod core;
|
||||
pub mod crypto;
|
||||
pub(crate) mod ethernet;
|
||||
pub(crate) mod fec;
|
||||
pub mod nat;
|
||||
pub mod protocol;
|
||||
|
||||
@@ -5,17 +5,27 @@ use pnet_packet::Packet;
|
||||
use pnet_packet::icmp::echo_reply::{Identifier, SequenceNumber};
|
||||
use pnet_packet::icmp::{IcmpPacket, IcmpTypes};
|
||||
use pnet_packet::ipv4::Ipv4Packet;
|
||||
use rust_p2p_core::socket::LocalInterface;
|
||||
use std::collections::HashMap;
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
|
||||
use std::time::{Duration, Instant};
|
||||
use tcp_ip::IpStack;
|
||||
use tcp_ip::icmp::IcmpSocket;
|
||||
use tokio::net::UdpSocket;
|
||||
|
||||
/// ICMP echo 映射超时:正常 ping 应答在秒级返回,超时条目视为无应答残留
|
||||
const ICMP_NAT_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
const ICMP_NAT_GC_INTERVAL: Duration = Duration::from_secs(60);
|
||||
|
||||
/// (对端地址, identifier, sequence) -> (内网客户端地址, 创建时间)
|
||||
type IcmpNatMap = HashMap<(Ipv4Addr, Identifier, SequenceNumber), (Ipv4Addr, Instant)>;
|
||||
|
||||
pub async fn start_icmp_nat(
|
||||
task_group: &TaskGroup,
|
||||
ip_stack: &IpStack,
|
||||
no_tun: bool,
|
||||
network: SharedNetworkAddr,
|
||||
default_interface: Option<LocalInterface>,
|
||||
) -> anyhow::Result<()> {
|
||||
let net_icmp_socket = socket2::Socket::new(
|
||||
socket2::Domain::IPV4,
|
||||
@@ -23,6 +33,12 @@ pub async fn start_icmp_nat(
|
||||
Some(socket2::Protocol::ICMPV4),
|
||||
)
|
||||
.context("new Socket RAW ICMPV4 failed")?;
|
||||
crate::utils::socket::bind_socket_to_interface(
|
||||
&net_icmp_socket,
|
||||
default_interface.as_ref(),
|
||||
true,
|
||||
)
|
||||
.context("bind ICMP socket to outbound interface failed")?;
|
||||
let addr: SocketAddrV4 = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0);
|
||||
net_icmp_socket
|
||||
.bind(&socket2::SockAddr::from(addr))
|
||||
@@ -49,24 +65,58 @@ async fn task(
|
||||
) -> anyhow::Result<()> {
|
||||
let mut buf1 = vec![0u8; 65536];
|
||||
let mut buf2 = vec![0u8; 65536];
|
||||
let mut map = HashMap::new();
|
||||
let mut map: IcmpNatMap = HashMap::new();
|
||||
let mut gc_interval = tokio::time::interval(ICMP_NAT_GC_INTERVAL);
|
||||
loop {
|
||||
// 单次收发/处理失败不能拖垮整个任务:记录日志后继续,
|
||||
// 短暂休眠避免持续性错误造成空转
|
||||
tokio::select! {
|
||||
rs = tokio_icmp_socket.recv(&mut buf1) => {
|
||||
let len = rs?;
|
||||
tokio_icmp_socket_recv(&buf1[..len],&inner_icmp_socket,&map,no_tun,&network).await?;
|
||||
match rs {
|
||||
Ok(len) => {
|
||||
if let Err(e) = tokio_icmp_socket_recv(&buf1[..len],&inner_icmp_socket,&mut map,no_tun,&network).await {
|
||||
log::warn!("icmp nat outbound error: {e:?}");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("icmp nat recv error: {e:?}");
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
rs = inner_icmp_socket.recv_from_to(&mut buf2) => {
|
||||
let (len,src,dst) = rs?;
|
||||
inner_icmp_socket_recv(&buf2[..len],src,dst,&tokio_icmp_socket,&mut map,no_tun,&network).await?;
|
||||
match rs {
|
||||
Ok((len,src,dst)) => {
|
||||
if let Err(e) = inner_icmp_socket_recv(&buf2[..len],src,dst,&tokio_icmp_socket,&mut map,no_tun,&network).await {
|
||||
log::warn!("icmp nat inbound error: {e:?}");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("icmp nat inner recv error: {e:?}");
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = gc_interval.tick() => {
|
||||
evict_expired(&mut map, Instant::now(), ICMP_NAT_TIMEOUT);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理超时未收到应答的映射条目,防止 map 无界增长
|
||||
fn evict_expired(map: &mut IcmpNatMap, now: Instant, timeout: Duration) {
|
||||
let before = map.len();
|
||||
map.retain(|_, (_, created)| now.duration_since(*created) < timeout);
|
||||
let evicted = before - map.len();
|
||||
if evicted > 0 {
|
||||
log::debug!("icmp nat evicted {} expired entries", evicted);
|
||||
}
|
||||
}
|
||||
async fn tokio_icmp_socket_recv(
|
||||
buf: &[u8],
|
||||
inner_icmp_socket: &IcmpSocket,
|
||||
map: &HashMap<(Ipv4Addr, Identifier, SequenceNumber), Ipv4Addr>,
|
||||
map: &mut IcmpNatMap,
|
||||
no_tun: bool,
|
||||
network: &SharedNetworkAddr,
|
||||
) -> anyhow::Result<()> {
|
||||
@@ -88,15 +138,20 @@ async fn tokio_icmp_socket_recv(
|
||||
let mut src = ipv4.get_source();
|
||||
let identifier = Identifier::new(u16::from_be_bytes([payload[0], payload[1]]));
|
||||
let sequence_number = SequenceNumber::new(u16::from_be_bytes([payload[2], payload[3]]));
|
||||
let Some(dst) = map.get(&(src, identifier, sequence_number)) else {
|
||||
// 收到应答即完成一次 echo 交换,移除映射,避免条目残留
|
||||
let Some((dst, _)) = map.remove(&(src, identifier, sequence_number)) else {
|
||||
return Ok(());
|
||||
};
|
||||
if no_tun && src == Ipv4Addr::LOCALHOST {
|
||||
src = network.ip().context("not ip")?;
|
||||
// 虚拟地址未就绪时丢弃该应答包,而不是让错误传播杀掉整个任务
|
||||
let Some(ip) = network.ip() else {
|
||||
return Ok(());
|
||||
};
|
||||
src = ip;
|
||||
}
|
||||
|
||||
inner_icmp_socket
|
||||
.send_from_to(ipv4.payload(), src.into(), (*dst).into())
|
||||
.send_from_to(ipv4.payload(), src.into(), dst.into())
|
||||
.await
|
||||
.context("sending ICMPv4 failed")?;
|
||||
Ok(())
|
||||
@@ -106,7 +161,7 @@ async fn inner_icmp_socket_recv(
|
||||
src: IpAddr,
|
||||
dst: IpAddr,
|
||||
tokio_icmp_socket: &UdpSocket,
|
||||
map: &mut HashMap<(Ipv4Addr, Identifier, SequenceNumber), Ipv4Addr>,
|
||||
map: &mut IcmpNatMap,
|
||||
no_tun: bool,
|
||||
network: &SharedNetworkAddr,
|
||||
) -> anyhow::Result<()> {
|
||||
@@ -125,15 +180,59 @@ async fn inner_icmp_socket_recv(
|
||||
if payload.len() < 4 {
|
||||
return Ok(());
|
||||
}
|
||||
if no_tun && dst == network.ip().context("not ip")? {
|
||||
// 虚拟地址未就绪(None)时跳过重写,而不是让错误传播杀掉整个任务
|
||||
if no_tun && Some(dst) == network.ip() {
|
||||
dst = Ipv4Addr::LOCALHOST;
|
||||
}
|
||||
|
||||
let identifier = Identifier::new(u16::from_be_bytes([payload[0], payload[1]]));
|
||||
let sequence_number = SequenceNumber::new(u16::from_be_bytes([payload[2], payload[3]]));
|
||||
map.insert((dst, identifier, sequence_number), src);
|
||||
map.insert((dst, identifier, sequence_number), (src, Instant::now()));
|
||||
tokio_icmp_socket
|
||||
.send_to(buf, SocketAddr::new(dst.into(), 0))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(
|
||||
ip: &str,
|
||||
id: u16,
|
||||
seq: u16,
|
||||
created: Instant,
|
||||
) -> ((Ipv4Addr, Identifier, SequenceNumber), (Ipv4Addr, Instant)) {
|
||||
(
|
||||
(
|
||||
ip.parse().unwrap(),
|
||||
Identifier::new(id),
|
||||
SequenceNumber::new(seq),
|
||||
),
|
||||
(Ipv4Addr::new(10, 0, 0, 1), created),
|
||||
)
|
||||
}
|
||||
|
||||
/// 超时未应答的条目必须被清理,未超时的保留,map 不会无界增长
|
||||
#[test]
|
||||
fn test_evict_expired() {
|
||||
let now = Instant::now();
|
||||
let mut map: IcmpNatMap = HashMap::new();
|
||||
let (k_fresh, v_fresh) = entry("8.8.8.8", 1, 1, now);
|
||||
let (k_stale, v_stale) = entry(
|
||||
"1.1.1.1",
|
||||
2,
|
||||
2,
|
||||
now - ICMP_NAT_TIMEOUT - Duration::from_secs(1),
|
||||
);
|
||||
map.insert(k_fresh, v_fresh);
|
||||
map.insert(k_stale, v_stale);
|
||||
|
||||
evict_expired(&mut map, now, ICMP_NAT_TIMEOUT);
|
||||
|
||||
assert_eq!(map.len(), 1);
|
||||
assert!(map.contains_key(&k_fresh));
|
||||
assert!(!map.contains_key(&k_stale));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use anyhow::Context;
|
||||
use bytes::BytesMut;
|
||||
use pnet_packet::ip::IpNextHeaderProtocol;
|
||||
use pnet_packet::ipv4::Ipv4Packet;
|
||||
use rust_p2p_core::socket::LocalInterface;
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
@@ -24,6 +25,7 @@ pub(crate) struct InternalNatInbound {
|
||||
ip_stack_send: Arc<IpStackSend>,
|
||||
allow_subnet: AllowSubnetExternalRoute,
|
||||
network: SharedNetworkAddr,
|
||||
default_interface: Option<LocalInterface>,
|
||||
}
|
||||
impl InternalNatInbound {
|
||||
pub async fn create(
|
||||
@@ -33,16 +35,28 @@ impl InternalNatInbound {
|
||||
allow_subnet: AllowSubnetExternalRoute,
|
||||
network: SharedNetworkAddr,
|
||||
no_tun: bool,
|
||||
default_interface: Option<LocalInterface>,
|
||||
) -> anyhow::Result<Self> {
|
||||
let ip_stack_config = IpStackConfig {
|
||||
mtu,
|
||||
..Default::default()
|
||||
};
|
||||
let ip_stack_config = IpStackConfig::builder().mtu(mtu).build();
|
||||
let (ip_stack, ip_stack_send, ip_stack_recv) = tcp_ip::ip_stack(ip_stack_config)?;
|
||||
#[cfg(not(target_os = "android"))]
|
||||
icmp_nat::start_icmp_nat(task_group, &ip_stack, no_tun, network.clone()).await?;
|
||||
tcp_nat::start_tcp_nat(task_group, &ip_stack, no_tun, network.clone()).await?;
|
||||
udp_nat::start_udp_nat(task_group, &ip_stack).await?;
|
||||
icmp_nat::start_icmp_nat(
|
||||
task_group,
|
||||
&ip_stack,
|
||||
no_tun,
|
||||
network.clone(),
|
||||
default_interface.clone(),
|
||||
)
|
||||
.await?;
|
||||
tcp_nat::start_tcp_nat(
|
||||
task_group,
|
||||
&ip_stack,
|
||||
no_tun,
|
||||
network.clone(),
|
||||
default_interface.clone(),
|
||||
)
|
||||
.await?;
|
||||
udp_nat::start_udp_nat(task_group, &ip_stack, default_interface.clone()).await?;
|
||||
task_group.spawn(async move {
|
||||
if let Err(e) = ip_stack_recv_task(ip_stack_recv, hybrid_outbound).await {
|
||||
log::error!("ip stack recv task error: {e:?}");
|
||||
@@ -53,10 +67,11 @@ impl InternalNatInbound {
|
||||
ip_stack_send: Arc::new(ip_stack_send),
|
||||
allow_subnet,
|
||||
network,
|
||||
default_interface,
|
||||
})
|
||||
}
|
||||
pub async fn send(&self, data: &[u8], net: &NetworkAddr) -> anyhow::Result<()> {
|
||||
if data[0] >> 4 != 4 {
|
||||
if data.is_empty() || data[0] >> 4 != 4 {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(ipv4) = Ipv4Packet::new(data) else {
|
||||
@@ -143,7 +158,13 @@ impl InternalNatInbound {
|
||||
}
|
||||
}
|
||||
let dst = SocketAddr::new(dest_ip.into(), dest_port);
|
||||
tcp_nat::stream_nat(recv_stream, send_stream, dst).await
|
||||
tcp_nat::stream_nat(
|
||||
recv_stream,
|
||||
send_stream,
|
||||
dst,
|
||||
self.default_interface.as_ref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,14 +173,21 @@ pub(crate) struct PortMappingManager {
|
||||
no_tun: bool,
|
||||
allow_port_mapping: bool,
|
||||
network: SharedNetworkAddr,
|
||||
default_interface: Option<LocalInterface>,
|
||||
}
|
||||
|
||||
impl PortMappingManager {
|
||||
pub fn new(no_tun: bool, allow_port_mapping: bool, network: SharedNetworkAddr) -> Self {
|
||||
pub fn new(
|
||||
no_tun: bool,
|
||||
allow_port_mapping: bool,
|
||||
network: SharedNetworkAddr,
|
||||
default_interface: Option<LocalInterface>,
|
||||
) -> Self {
|
||||
Self {
|
||||
no_tun,
|
||||
allow_port_mapping,
|
||||
network,
|
||||
default_interface,
|
||||
}
|
||||
}
|
||||
pub async fn tcp_mapping<R, W>(
|
||||
@@ -184,13 +212,25 @@ impl PortMappingManager {
|
||||
|
||||
if dest_ip == net.ip {
|
||||
let dst = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), dest_port);
|
||||
return tcp_nat::stream_nat(recv_stream, send_stream, dst).await;
|
||||
return tcp_nat::stream_nat(
|
||||
recv_stream,
|
||||
send_stream,
|
||||
dst,
|
||||
self.default_interface.as_ref(),
|
||||
)
|
||||
.await;
|
||||
} else if net.network().contains(&dest_ip) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
let dst = format!("{}:{}", dest, dest_port);
|
||||
tcp_nat::stream_nat(recv_stream, send_stream, dst).await
|
||||
tcp_nat::stream_nat(
|
||||
recv_stream,
|
||||
send_stream,
|
||||
dst,
|
||||
self.default_interface.as_ref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
pub async fn udp_mapping<R, W>(
|
||||
&self,
|
||||
@@ -214,12 +254,24 @@ impl PortMappingManager {
|
||||
|
||||
if dest_ip == net.ip {
|
||||
let dst = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), dest_port);
|
||||
return udp_nat::stream_nat(recv_stream, send_stream, dst).await;
|
||||
return udp_nat::stream_nat(
|
||||
recv_stream,
|
||||
send_stream,
|
||||
dst,
|
||||
self.default_interface.as_ref(),
|
||||
)
|
||||
.await;
|
||||
} else if net.network().contains(&dest_ip) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
let dst = format!("{}:{}", dest, dest_port);
|
||||
udp_nat::stream_nat(recv_stream, send_stream, dst).await
|
||||
udp_nat::stream_nat(
|
||||
recv_stream,
|
||||
send_stream,
|
||||
dst,
|
||||
self.default_interface.as_ref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
use crate::context::SharedNetworkAddr;
|
||||
use crate::utils::task_control::TaskGroup;
|
||||
use anyhow::Context;
|
||||
use rust_p2p_core::socket::LocalInterface;
|
||||
use std::fmt::Debug;
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use tcp_ip::IpStack;
|
||||
use tcp_ip::tcp::TcpListener;
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
use tokio::net::{TcpStream, ToSocketAddrs};
|
||||
use tokio::net::ToSocketAddrs;
|
||||
|
||||
pub async fn start_tcp_nat(
|
||||
task_group: &TaskGroup,
|
||||
ip_stack: &IpStack,
|
||||
no_tun: bool,
|
||||
network: SharedNetworkAddr,
|
||||
default_interface: Option<LocalInterface>,
|
||||
) -> anyhow::Result<()> {
|
||||
let tcp_listener = TcpListener::bind_all(ip_stack.clone()).await?;
|
||||
let group = task_group.clone();
|
||||
task_group.spawn(async move {
|
||||
if let Err(e) = listen_task(&group, tcp_listener, no_tun, network).await {
|
||||
if let Err(e) = listen_task(&group, tcp_listener, no_tun, network, default_interface).await
|
||||
{
|
||||
log::error!("listen task error: {:?}", e);
|
||||
}
|
||||
});
|
||||
@@ -29,22 +32,41 @@ async fn listen_task(
|
||||
mut tcp_listener: TcpListener,
|
||||
no_tun: bool,
|
||||
network: SharedNetworkAddr,
|
||||
default_interface: Option<LocalInterface>,
|
||||
) -> anyhow::Result<()> {
|
||||
loop {
|
||||
let (stream, _addr) = tcp_listener.accept().await?;
|
||||
let mut local_addr = stream.local_addr()?;
|
||||
let peer_addr = stream.peer_addr()?;
|
||||
// 单次 accept/地址查询失败不能拖垮整个监听任务:
|
||||
// 记录日志后继续,短暂休眠避免持续性错误造成空转
|
||||
let (stream, _addr) = match tcp_listener.accept().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log::warn!("tcp nat accept error: {e:?}");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let (mut local_addr, peer_addr) = match (stream.local_addr(), stream.peer_addr()) {
|
||||
(Ok(local_addr), Ok(peer_addr)) => (local_addr, peer_addr),
|
||||
(Err(e), _) | (_, Err(e)) => {
|
||||
log::warn!("tcp nat get addr error: {e:?}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if no_tun {
|
||||
let IpAddr::V4(ip) = local_addr.ip() else {
|
||||
continue;
|
||||
};
|
||||
if ip == network.ip().context("not ip")? {
|
||||
// 虚拟地址未就绪(None)时跳过重写,而不是终止任务
|
||||
if let Some(net_ip) = network.ip()
|
||||
&& ip == net_ip
|
||||
{
|
||||
// 无tun的情况下写入本机的则写到localhost
|
||||
local_addr.set_ip(IpAddr::V4(Ipv4Addr::LOCALHOST));
|
||||
}
|
||||
}
|
||||
let default_interface = default_interface.clone();
|
||||
task_group.spawn(async move {
|
||||
if let Err(e) = stream_task(stream, local_addr).await {
|
||||
if let Err(e) = stream_task(stream, local_addr, default_interface.as_ref()).await {
|
||||
log::error!("stream task Error: {:?},{peer_addr}->{local_addr}", e);
|
||||
}
|
||||
});
|
||||
@@ -54,28 +76,31 @@ async fn listen_task(
|
||||
async fn stream_task(
|
||||
mut inner_stream: tcp_ip::tcp::TcpStream,
|
||||
addr: SocketAddr,
|
||||
default_interface: Option<&LocalInterface>,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut tokio_stream = TcpStream::connect(addr).await?;
|
||||
let mut tokio_stream = crate::utils::socket::connect_tcp(addr, default_interface).await?;
|
||||
tokio::io::copy_bidirectional(&mut inner_stream, &mut tokio_stream).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn stream_nat<R, W, A: ToSocketAddrs + Debug>(
|
||||
mut recv_stream: R,
|
||||
mut send_stream: W,
|
||||
recv_stream: R,
|
||||
send_stream: W,
|
||||
addr: A,
|
||||
default_interface: Option<&LocalInterface>,
|
||||
) -> anyhow::Result<()>
|
||||
where
|
||||
R: AsyncRead + Unpin,
|
||||
W: AsyncWrite + Unpin,
|
||||
{
|
||||
let mut tokio_stream = TcpStream::connect(&addr)
|
||||
let mut tokio_stream = crate::utils::socket::connect_tcp_resolved(addr, default_interface)
|
||||
.await
|
||||
.with_context(|| format!("error connecting to {:?}", addr))?;
|
||||
let (mut tcp_r, mut tcp_w) = tokio_stream.split();
|
||||
tokio::select! {
|
||||
_ = tokio::io::copy(&mut recv_stream, &mut tcp_w) => {},
|
||||
_ = tokio::io::copy(&mut tcp_r, &mut send_stream) => {},
|
||||
}
|
||||
.context("error connecting to NAT destination")?;
|
||||
crate::port_mapping::tcp_port_mapping::copy_bidirectional_split(
|
||||
&mut tokio_stream,
|
||||
recv_stream,
|
||||
send_stream,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use crate::utils::task_control::TaskGroup;
|
||||
use crate::utils::task_control::{SubTask, TaskGroup};
|
||||
use anyhow::Context;
|
||||
use bytes::Bytes;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use rust_p2p_core::socket::LocalInterface;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Debug;
|
||||
use std::net::SocketAddr;
|
||||
@@ -16,6 +17,8 @@ use tokio_util::codec::{FramedRead, FramedWrite, LengthDelimitedCodec};
|
||||
struct NatEntry {
|
||||
socket: Arc<tokio::net::UdpSocket>,
|
||||
last_active: Instant,
|
||||
/// 反向转发任务,条目过期回收时需要一并终止,否则任务与 socket 永久残留
|
||||
inbound_task: SubTask,
|
||||
}
|
||||
|
||||
type NatTable = Arc<Mutex<HashMap<(SocketAddr, SocketAddr), NatEntry>>>;
|
||||
@@ -23,7 +26,11 @@ type NatTable = Arc<Mutex<HashMap<(SocketAddr, SocketAddr), NatEntry>>>;
|
||||
const NAT_IDLE_TIMEOUT: Duration = Duration::from_secs(60 * 5);
|
||||
const NAT_GC_INTERVAL: Duration = Duration::from_secs(60);
|
||||
|
||||
pub async fn start_udp_nat(task_group: &TaskGroup, ip_stack: &IpStack) -> anyhow::Result<()> {
|
||||
pub async fn start_udp_nat(
|
||||
task_group: &TaskGroup,
|
||||
ip_stack: &IpStack,
|
||||
default_interface: Option<LocalInterface>,
|
||||
) -> anyhow::Result<()> {
|
||||
let inner_socket = tcp_ip::udp::UdpSocket::bind_all(ip_stack.clone()).await?;
|
||||
let inner_socket = Arc::new(inner_socket);
|
||||
let nat_table: NatTable = Arc::new(Mutex::new(HashMap::new()));
|
||||
@@ -40,8 +47,16 @@ pub async fn start_udp_nat(task_group: &TaskGroup, ip_stack: &IpStack) -> anyhow
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) =
|
||||
handle_outbound(&group, &inner_socket, &nat_table, src, dst, &buf[..len]).await
|
||||
if let Err(e) = handle_outbound(
|
||||
&group,
|
||||
&inner_socket,
|
||||
&nat_table,
|
||||
src,
|
||||
dst,
|
||||
&buf[..len],
|
||||
default_interface.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::warn!("udp nat outbound error: {e:?}");
|
||||
}
|
||||
@@ -58,6 +73,7 @@ async fn handle_outbound(
|
||||
src: SocketAddr,
|
||||
dst: SocketAddr,
|
||||
packet: &[u8],
|
||||
default_interface: Option<&LocalInterface>,
|
||||
) -> anyhow::Result<()> {
|
||||
let key = (src, dst);
|
||||
|
||||
@@ -68,19 +84,22 @@ async fn handle_outbound(
|
||||
entry.socket.clone()
|
||||
} else {
|
||||
// 创建真实 UDP socket
|
||||
let sock = tokio::net::UdpSocket::bind("0.0.0.0:0").await?;
|
||||
let bind_addr = if dst.is_ipv4() {
|
||||
"0.0.0.0:0".parse().expect("valid IPv4 bind address")
|
||||
} else {
|
||||
"[::]:0".parse().expect("valid IPv6 bind address")
|
||||
};
|
||||
let interface = if dst.ip().is_loopback() {
|
||||
None
|
||||
} else {
|
||||
default_interface
|
||||
};
|
||||
let sock = crate::utils::socket::bind_udp(bind_addr, interface)?;
|
||||
sock.connect(dst).await?;
|
||||
let sock = Arc::new(sock);
|
||||
table.insert(
|
||||
key,
|
||||
NatEntry {
|
||||
socket: sock.clone(),
|
||||
last_active: Instant::now(),
|
||||
},
|
||||
);
|
||||
|
||||
// 启动反向转发
|
||||
spawn_inbound(
|
||||
let inbound_task = spawn_inbound(
|
||||
task_group,
|
||||
inner.clone(),
|
||||
nat.clone(),
|
||||
@@ -89,6 +108,15 @@ async fn handle_outbound(
|
||||
sock.clone(),
|
||||
);
|
||||
|
||||
table.insert(
|
||||
key,
|
||||
NatEntry {
|
||||
socket: sock.clone(),
|
||||
last_active: Instant::now(),
|
||||
inbound_task,
|
||||
},
|
||||
);
|
||||
|
||||
sock
|
||||
}
|
||||
};
|
||||
@@ -104,7 +132,7 @@ fn spawn_inbound(
|
||||
src: SocketAddr,
|
||||
dst: SocketAddr,
|
||||
socket: Arc<tokio::net::UdpSocket>,
|
||||
) {
|
||||
) -> SubTask {
|
||||
task_group.spawn(async move {
|
||||
let mut buf = vec![0u8; 65536];
|
||||
|
||||
@@ -125,9 +153,26 @@ fn spawn_inbound(
|
||||
}
|
||||
}
|
||||
|
||||
// 回收 NAT
|
||||
nat.lock().await.remove(&(src, dst));
|
||||
});
|
||||
// 回收 NAT:仅当表中的条目仍是本任务持有的 socket 时才删除,
|
||||
// 避免条目过期被 GC 回收并重建后,旧任务误删新条目
|
||||
let mut table = nat.lock().await;
|
||||
remove_if_current(&mut table, &(src, dst), &socket);
|
||||
})
|
||||
}
|
||||
|
||||
/// 仅当映射中的条目仍持有同一个 socket(即仍是当前任务对应的条目)时才删除
|
||||
fn remove_if_current(
|
||||
table: &mut HashMap<(SocketAddr, SocketAddr), NatEntry>,
|
||||
key: &(SocketAddr, SocketAddr),
|
||||
socket: &Arc<tokio::net::UdpSocket>,
|
||||
) -> bool {
|
||||
if let Some(entry) = table.get(key)
|
||||
&& Arc::ptr_eq(&entry.socket, socket)
|
||||
{
|
||||
table.remove(key);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn spawn_nat_gc(task_group: &TaskGroup, nat: NatTable) {
|
||||
@@ -138,15 +183,27 @@ fn spawn_nat_gc(task_group: &TaskGroup, nat: NatTable) {
|
||||
interval.tick().await;
|
||||
|
||||
let now = Instant::now();
|
||||
let expired_tasks = {
|
||||
let mut table = nat.lock().await;
|
||||
let expired_keys: Vec<(SocketAddr, SocketAddr)> = table
|
||||
.iter()
|
||||
.filter(|(_, entry)| now.duration_since(entry.last_active) >= NAT_IDLE_TIMEOUT)
|
||||
.map(|(key, _)| *key)
|
||||
.collect();
|
||||
let mut tasks = Vec::with_capacity(expired_keys.len());
|
||||
for key in expired_keys {
|
||||
if let Some(entry) = table.remove(&key) {
|
||||
log::debug!("udp nat expired: {} -> {}", key.0, key.1);
|
||||
tasks.push(entry.inbound_task);
|
||||
}
|
||||
}
|
||||
tasks
|
||||
};
|
||||
|
||||
table.retain(|(src, dst), entry| {
|
||||
let alive = now.duration_since(entry.last_active) < NAT_IDLE_TIMEOUT;
|
||||
if !alive {
|
||||
log::debug!("udp nat expired: {} -> {}", src, dst);
|
||||
// 终止过期条目的反向转发任务,释放其持有的 socket
|
||||
for task in expired_tasks {
|
||||
task.stop().await;
|
||||
}
|
||||
alive
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -155,16 +212,28 @@ pub(crate) async fn stream_nat<R, W, A: ToSocketAddrs + Debug>(
|
||||
recv_stream: R,
|
||||
send_stream: W,
|
||||
addr: A,
|
||||
default_interface: Option<&LocalInterface>,
|
||||
) -> anyhow::Result<()>
|
||||
where
|
||||
R: AsyncRead + Unpin,
|
||||
W: AsyncWrite + Unpin,
|
||||
{
|
||||
let udp_socket = tokio::net::UdpSocket::bind("0.0.0.0:0").await?;
|
||||
udp_socket
|
||||
.connect(&addr)
|
||||
.await
|
||||
.with_context(|| format!("error connecting to {:?}", addr))?;
|
||||
let destination = tokio::net::lookup_host(addr)
|
||||
.await?
|
||||
.next()
|
||||
.context("UDP NAT destination resolved to no address")?;
|
||||
let bind_addr = if destination.is_ipv4() {
|
||||
"0.0.0.0:0".parse().expect("valid IPv4 bind address")
|
||||
} else {
|
||||
"[::]:0".parse().expect("valid IPv6 bind address")
|
||||
};
|
||||
let interface = if destination.ip().is_loopback() {
|
||||
None
|
||||
} else {
|
||||
default_interface
|
||||
};
|
||||
let udp_socket = crate::utils::socket::bind_udp(bind_addr, interface)?;
|
||||
udp_socket.connect(destination).await?;
|
||||
let mut framed_read = FramedRead::new(recv_stream, LengthDelimitedCodec::new());
|
||||
let mut framed_write = FramedWrite::new(send_stream, LengthDelimitedCodec::new());
|
||||
let mut buf = vec![0u8; 65536];
|
||||
@@ -186,3 +255,63 @@ where
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::utils::task_control::TaskGroupManager;
|
||||
|
||||
async fn new_entry() -> (NatEntry, Arc<tokio::net::UdpSocket>) {
|
||||
let socket = Arc::new(tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap());
|
||||
let manager = TaskGroupManager::new();
|
||||
let (group, _guard) = manager.create_task().unwrap();
|
||||
let inbound_task = group.spawn(async {});
|
||||
(
|
||||
NatEntry {
|
||||
socket: socket.clone(),
|
||||
last_active: Instant::now(),
|
||||
inbound_task,
|
||||
},
|
||||
socket,
|
||||
)
|
||||
}
|
||||
|
||||
fn test_key() -> (SocketAddr, SocketAddr) {
|
||||
(
|
||||
"10.0.0.1:1000".parse().unwrap(),
|
||||
"8.8.8.8:53".parse().unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 误删竞态:条目过期被 GC 回收并以新 socket 重建后,
|
||||
/// 旧任务退出时不允许把新条目删掉。
|
||||
#[tokio::test]
|
||||
async fn test_remove_if_current_only_removes_same_socket() {
|
||||
let key = test_key();
|
||||
let mut table = HashMap::new();
|
||||
let (entry, socket) = new_entry().await;
|
||||
table.insert(key, entry);
|
||||
|
||||
// 旧任务持有的 socket 与表中条目不同(条目已重建):不允许删除
|
||||
let stale_socket = Arc::new(tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap());
|
||||
assert!(!remove_if_current(&mut table, &key, &stale_socket));
|
||||
assert!(table.contains_key(&key));
|
||||
|
||||
// 同一个 socket(条目确属本任务):允许删除
|
||||
assert!(remove_if_current(&mut table, &key, &socket));
|
||||
assert!(!table.contains_key(&key));
|
||||
}
|
||||
|
||||
/// 条目中的 inbound_task 可被正常终止(GC 回收路径依赖此能力释放任务与 socket)
|
||||
#[tokio::test]
|
||||
async fn test_entry_inbound_task_stoppable() {
|
||||
let manager = TaskGroupManager::new();
|
||||
let (group, _guard) = manager.create_task().unwrap();
|
||||
let task = group.spawn(async {
|
||||
tokio::time::sleep(Duration::from_secs(3600)).await;
|
||||
});
|
||||
assert!(task.is_running());
|
||||
task.stop().await;
|
||||
assert!(!task.is_running());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::sync::Arc;
|
||||
|
||||
pub(crate) mod internal_nat;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct NetInput {
|
||||
pub net: Ipv4Net,
|
||||
pub target_ip: Ipv4Addr,
|
||||
|
||||
@@ -7,8 +7,26 @@ use crate::utils::task_control::TaskGroup;
|
||||
use anyhow::Context;
|
||||
use pnet_packet::ip::IpNextHeaderProtocols;
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
/// 双向转发:任一方向 EOF 时对另一端执行 shutdown 并继续转发剩余方向,
|
||||
/// 直到两个方向都完成。避免 select! 下任一方向先结束就 drop 另一方向
|
||||
/// 造成的半关闭截断(如对端半关闭后响应数据丢失)。
|
||||
pub(crate) async fn copy_bidirectional_split<T, R, W>(
|
||||
stream: &mut T,
|
||||
reader: R,
|
||||
writer: W,
|
||||
) -> anyhow::Result<(u64, u64)>
|
||||
where
|
||||
T: AsyncRead + AsyncWrite + Unpin,
|
||||
R: AsyncRead + Unpin,
|
||||
W: AsyncWrite + Unpin,
|
||||
{
|
||||
let mut other = tokio::io::join(reader, writer);
|
||||
Ok(tokio::io::copy_bidirectional(stream, &mut other).await?)
|
||||
}
|
||||
|
||||
pub async fn start(
|
||||
task_group: &TaskGroup,
|
||||
list: &Vec<PortMapping>,
|
||||
@@ -64,7 +82,7 @@ async fn stream_copy(
|
||||
dst_port: u16,
|
||||
quic_tunnel_client: QuicTunnelClient,
|
||||
) -> anyhow::Result<()> {
|
||||
let (mut send_stream, mut recv_stream) = quic_tunnel_client.open_bi(target_ip).await?;
|
||||
let (mut send_stream, recv_stream) = quic_tunnel_client.open_bi(target_ip).await?;
|
||||
let handshake = QuicProxyHandshake {
|
||||
handshake: Some(quic_proxy_handshake::Handshake::TcpPortMapping(
|
||||
PortProxyHandshake {
|
||||
@@ -76,10 +94,46 @@ async fn stream_copy(
|
||||
)),
|
||||
};
|
||||
send_handshake(&mut send_stream, handshake).await?;
|
||||
let (mut tcp_r, mut tcp_w) = tcp_stream.split();
|
||||
tokio::select! {
|
||||
_ = tokio::io::copy(&mut recv_stream, &mut tcp_w) => {},
|
||||
_ = tokio::io::copy(&mut tcp_r, &mut send_stream) => {},
|
||||
}
|
||||
copy_bidirectional_split(&mut tcp_stream, recv_stream, send_stream).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
/// 半关闭场景:客户端发完请求后 shutdown 写方向,
|
||||
/// 服务端的响应必须完整送达,不能被截断。
|
||||
#[tokio::test]
|
||||
async fn test_half_close_no_truncation() {
|
||||
let (mut client, mut relay_tcp) = tokio::io::duplex(64);
|
||||
let (relay_tunnel, mut server) = tokio::io::duplex(64);
|
||||
let (relay_tunnel_r, relay_tunnel_w) = tokio::io::split(relay_tunnel);
|
||||
|
||||
let relay = tokio::spawn(async move {
|
||||
copy_bidirectional_split(&mut relay_tcp, relay_tunnel_r, relay_tunnel_w)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
// 客户端发请求后立即半关闭写方向
|
||||
client.write_all(b"ping").await.unwrap();
|
||||
client.shutdown().await.unwrap();
|
||||
|
||||
// 服务端读到完整请求(读到 EOF 前数据不能丢)
|
||||
let mut buf = [0u8; 4];
|
||||
server.read_exact(&mut buf).await.unwrap();
|
||||
assert_eq!(&buf, b"ping");
|
||||
// 服务端回响应并关闭
|
||||
server.write_all(b"pong").await.unwrap();
|
||||
server.shutdown().await.unwrap();
|
||||
|
||||
// 客户端必须能读到完整响应(旧实现此处会被截断为空)
|
||||
let mut out = Vec::new();
|
||||
client.read_to_end(&mut out).await.unwrap();
|
||||
assert_eq!(out, b"pong");
|
||||
|
||||
relay.await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,16 @@ async fn recv(
|
||||
let mut buf = vec![0u8; 65536];
|
||||
let dest_map = Arc::new(Mutex::new(HashMap::<SocketAddr, Sender<Bytes>>::new()));
|
||||
loop {
|
||||
let (len, src) = udp_socket.recv_from(&mut buf).await?;
|
||||
// 单次接收错误不能杀掉整个端口映射任务:记录日志后继续,
|
||||
// 短暂休眠避免持续性错误造成空转
|
||||
let (len, src) = match udp_socket.recv_from(&mut buf).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log::warn!("udp port mapping recv error: {e:?}");
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let bytes = Bytes::copy_from_slice(&buf[..len]);
|
||||
|
||||
let tx = {
|
||||
@@ -67,6 +76,8 @@ async fn recv(
|
||||
let dst_host = mapping.dst_host.clone();
|
||||
let dst_port = mapping.dst_port;
|
||||
let tunnel_client = quic_tunnel_client.clone();
|
||||
let tx_clone = tx.clone();
|
||||
let dest_map_clone = dest_map.clone();
|
||||
task_group.spawn(async move {
|
||||
if let Err(e) = udp_mapping_handle(
|
||||
udp_socket,
|
||||
@@ -81,6 +92,9 @@ async fn recv(
|
||||
{
|
||||
log::error!("udp_mapping_handle {e:?},src:{src}");
|
||||
}
|
||||
// 任务退出(含 60s 空闲超时)时回收映射条目,
|
||||
// 否则 dest_map 随不同 src 数量无界增长
|
||||
remove_if_same(&mut dest_map_clone.lock(), &src, &tx_clone);
|
||||
});
|
||||
|
||||
map.insert(src, tx.clone());
|
||||
@@ -100,6 +114,22 @@ async fn recv(
|
||||
}
|
||||
}
|
||||
|
||||
/// 仅当映射仍指向同一个 channel(即仍是本任务的条目)时才移除,
|
||||
/// 避免条目被回收重建后,旧任务误删新条目
|
||||
fn remove_if_same(
|
||||
map: &mut HashMap<SocketAddr, Sender<Bytes>>,
|
||||
src: &SocketAddr,
|
||||
tx: &Sender<Bytes>,
|
||||
) -> bool {
|
||||
if let Some(cur) = map.get(src)
|
||||
&& cur.same_channel(tx)
|
||||
{
|
||||
map.remove(src);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
async fn udp_mapping_handle(
|
||||
udp_socket: Arc<UdpSocket>,
|
||||
src: SocketAddr,
|
||||
@@ -143,3 +173,30 @@ async fn udp_mapping_handle(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 空闲回收竞态:条目被重建后,旧任务退出不得误删新条目
|
||||
#[test]
|
||||
fn test_remove_if_same() {
|
||||
let src: SocketAddr = "10.0.0.1:5000".parse().unwrap();
|
||||
let mut map = HashMap::new();
|
||||
let (old_tx, _old_rx) = tokio::sync::mpsc::channel::<Bytes>(1);
|
||||
map.insert(src, old_tx.clone());
|
||||
|
||||
// 条目被重建为新 channel
|
||||
let (new_tx, _new_rx) = tokio::sync::mpsc::channel::<Bytes>(1);
|
||||
map.insert(src, new_tx);
|
||||
|
||||
// 旧任务退出:不允许删除新条目
|
||||
assert!(!remove_if_same(&mut map, &src, &old_tx));
|
||||
assert!(map.contains_key(&src));
|
||||
|
||||
// 新任务退出:允许删除
|
||||
let new_tx = map.get(&src).unwrap().clone();
|
||||
assert!(remove_if_same(&mut map, &src, &new_tx));
|
||||
assert!(!map.contains_key(&src));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
0 15 31
|
||||
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
|
||||
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||
| 1 | msg_type(7) |max ttl(4) |curr ttl(4)| C | G | R | reserve(13) |
|
||||
| 1 | msg_type(7) |max ttl(4) |curr ttl(4)| C | G | F | E | reserve(12) |
|
||||
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||
| seq(32) |
|
||||
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||
@@ -27,7 +27,7 @@ pub struct NetHeader {
|
||||
pub type_byte: u8,
|
||||
/// Byte 1: high 4 = max ttl, low 4 = curr ttl
|
||||
pub ttl_byte: u8,
|
||||
/// Byte 2: C(0x80) | G(0x40) | reserve
|
||||
/// Byte 2: C(0x80) | G(0x40) | F(0x20) | ETHERNET(0x10) | reserve
|
||||
pub flags_byte: u8,
|
||||
/// Byte 3: reserve
|
||||
pub _reserved: u8,
|
||||
@@ -39,6 +39,7 @@ pub struct NetHeader {
|
||||
const COMPRESSED: u8 = 0x80;
|
||||
const GATEWAY: u8 = 0x40;
|
||||
const FEC: u8 = 0x20;
|
||||
const ETHERNET: u8 = 0x10;
|
||||
impl NetHeader {
|
||||
#[inline]
|
||||
pub fn msg_type(&self) -> u8 {
|
||||
@@ -106,6 +107,8 @@ pub enum MsgType {
|
||||
RpcRes = 15,
|
||||
|
||||
Quic = 17,
|
||||
RelayProbe = 18,
|
||||
RelayProbeReply = 19,
|
||||
}
|
||||
impl From<MsgType> for u8 {
|
||||
fn from(val: MsgType) -> Self {
|
||||
@@ -139,6 +142,8 @@ impl TryFrom<u8> for MsgType {
|
||||
15 => MsgType::RpcRes,
|
||||
|
||||
17 => MsgType::Quic,
|
||||
18 => MsgType::RelayProbe,
|
||||
19 => MsgType::RelayProbeReply,
|
||||
_ => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
@@ -206,6 +211,9 @@ impl<B: AsRef<[u8]>> NetPacket<B> {
|
||||
pub fn is_fec(&self) -> bool {
|
||||
(self.header().flags_byte & FEC) != 0
|
||||
}
|
||||
pub fn is_ethernet(&self) -> bool {
|
||||
(self.header().flags_byte & ETHERNET) != 0
|
||||
}
|
||||
pub fn head(&self) -> &[u8] {
|
||||
&self.buffer.as_ref()[..HEAD_LENGTH]
|
||||
}
|
||||
@@ -225,7 +233,7 @@ impl<B: AsRef<[u8]> + AsMut<[u8]>> NetPacket<B> {
|
||||
self.header_mut().set_msg_type(msg_type.into());
|
||||
}
|
||||
|
||||
pub fn decr_ttl(&mut self){
|
||||
pub fn decr_ttl(&mut self) {
|
||||
self.header_mut().decr_ttl()
|
||||
}
|
||||
|
||||
@@ -254,6 +262,9 @@ impl<B: AsRef<[u8]> + AsMut<[u8]>> NetPacket<B> {
|
||||
pub fn set_fec_flag(&mut self, fec: bool) {
|
||||
self.header_mut().set_flag(FEC, fec);
|
||||
}
|
||||
pub fn set_ethernet_flag(&mut self, ethernet: bool) {
|
||||
self.header_mut().set_flag(ETHERNET, ethernet);
|
||||
}
|
||||
|
||||
pub fn set_payload(&mut self, data: &[u8]) -> io::Result<()> {
|
||||
let buf = self.buffer.as_mut();
|
||||
@@ -300,3 +311,77 @@ impl NetPacket<TransmissionBytes> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn msg_type_round_trip() {
|
||||
let all = [
|
||||
MsgType::Turn,
|
||||
MsgType::Broadcast,
|
||||
MsgType::ExcludeBroadcast,
|
||||
MsgType::TargetBroadcast,
|
||||
MsgType::Ping,
|
||||
MsgType::Pong,
|
||||
MsgType::PingTurn,
|
||||
MsgType::PongTurn,
|
||||
MsgType::PunchStart1,
|
||||
MsgType::PunchStart2,
|
||||
MsgType::PunchReq,
|
||||
MsgType::PunchRes,
|
||||
MsgType::PushClientIps,
|
||||
MsgType::RpcReq,
|
||||
MsgType::RpcRes,
|
||||
MsgType::Quic,
|
||||
MsgType::RelayProbe,
|
||||
MsgType::RelayProbeReply,
|
||||
];
|
||||
for msg_type in all {
|
||||
let byte = u8::from(msg_type);
|
||||
assert_eq!(
|
||||
MsgType::try_from(byte).unwrap(),
|
||||
msg_type,
|
||||
"round trip failed for {msg_type:?} ({byte})"
|
||||
);
|
||||
}
|
||||
// 未分配的取值必须报错
|
||||
assert!(MsgType::try_from(0u8).is_err());
|
||||
assert!(MsgType::try_from(16u8).is_err());
|
||||
assert!(MsgType::try_from(20u8).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ethernet_flag_round_trip() {
|
||||
let mut packet = NetPacket::new(BytesMut::from(&[0u8; HEAD_LENGTH][..])).unwrap();
|
||||
assert!(!packet.is_ethernet());
|
||||
packet.set_ethernet_flag(true);
|
||||
assert!(packet.is_ethernet());
|
||||
packet.set_fec_flag(true);
|
||||
assert!(packet.is_ethernet());
|
||||
packet.set_ethernet_flag(false);
|
||||
assert!(!packet.is_ethernet());
|
||||
assert!(packet.is_fec());
|
||||
}
|
||||
|
||||
/// 中继转发语义:包每经过一跳 curr_ttl 减 1,curr_ttl >= 1 时才继续转发,
|
||||
/// 接收方以 metric = max_ttl - curr_ttl 计算路由距离。
|
||||
#[test]
|
||||
fn relay_reply_survives_one_hop() {
|
||||
let mut packet = NetPacket::new(BytesMut::from(&[0u8; HEAD_LENGTH][..])).unwrap();
|
||||
packet.set_msg_type(MsgType::RelayProbeReply);
|
||||
// 目标方回复时 TTL 必须允许一次中继
|
||||
packet.set_ttl(2);
|
||||
|
||||
// 中继节点:decr 后 curr_ttl == 1,满足转发条件 ttl >= 1
|
||||
packet.decr_ttl();
|
||||
assert_eq!(packet.ttl(), 1);
|
||||
assert!(packet.ttl() >= 1, "relay node would drop this packet");
|
||||
|
||||
// 发起方:decr 后 curr_ttl == 0,metric = 2(经由一个中继)
|
||||
packet.decr_ttl();
|
||||
assert_eq!(packet.ttl(), 0);
|
||||
assert_eq!(packet.max_ttl() - packet.ttl(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,14 @@ use std::str::FromStr;
|
||||
#[derive(Debug)]
|
||||
pub struct FingerprintVerifier {
|
||||
pub expected_fingerprint: [u8; 32],
|
||||
supported_algorithms: rustls::crypto::WebPkiSupportedAlgorithms,
|
||||
}
|
||||
impl FingerprintVerifier {
|
||||
pub fn new(expected_fingerprint: [u8; 32]) -> Self {
|
||||
Self {
|
||||
expected_fingerprint,
|
||||
supported_algorithms: rustls::crypto::ring::default_provider()
|
||||
.signature_verification_algorithms,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,39 +48,26 @@ impl ServerCertVerifier for FingerprintVerifier {
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &CertificateDer<'_>,
|
||||
_dss: &rustls::DigitallySignedStruct,
|
||||
message: &[u8],
|
||||
cert: &CertificateDer<'_>,
|
||||
dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(HandshakeSignatureValid::assertion())
|
||||
// 必须真正验证握手签名:证书本身(由密码确定性生成)是公开信息,
|
||||
// 只比对指纹而不验签无法抵抗重放真实证书的主动中间人
|
||||
rustls::crypto::verify_tls12_signature(message, cert, dss, &self.supported_algorithms)
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &CertificateDer<'_>,
|
||||
_dss: &rustls::DigitallySignedStruct,
|
||||
message: &[u8],
|
||||
cert: &CertificateDer<'_>,
|
||||
dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(HandshakeSignatureValid::assertion())
|
||||
rustls::crypto::verify_tls13_signature(message, cert, dss, &self.supported_algorithms)
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
vec![
|
||||
// RSA schemes
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA256,
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA384,
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA512,
|
||||
rustls::SignatureScheme::RSA_PSS_SHA256,
|
||||
rustls::SignatureScheme::RSA_PSS_SHA384,
|
||||
rustls::SignatureScheme::RSA_PSS_SHA512,
|
||||
// ECDSA schemes
|
||||
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
|
||||
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
|
||||
rustls::SignatureScheme::ECDSA_NISTP521_SHA512,
|
||||
// EdDSA schemes
|
||||
rustls::SignatureScheme::ED25519,
|
||||
rustls::SignatureScheme::ED448,
|
||||
]
|
||||
self.supported_algorithms.supported_schemes()
|
||||
}
|
||||
}
|
||||
#[derive(Debug)]
|
||||
@@ -202,9 +192,7 @@ impl CertValidationMode {
|
||||
Ok(std::sync::Arc::new(InsecureVerifier))
|
||||
}
|
||||
CertValidationMode::VerifyFingerprint(fingerprint) => {
|
||||
Ok(std::sync::Arc::new(FingerprintVerifier {
|
||||
expected_fingerprint: *fingerprint,
|
||||
}))
|
||||
Ok(std::sync::Arc::new(FingerprintVerifier::new(*fingerprint)))
|
||||
}
|
||||
CertValidationMode::Standard => {
|
||||
let root_store = load_root_cert()?;
|
||||
@@ -226,3 +214,73 @@ impl CertValidationMode {
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tls::cert::generate_deterministic_cert;
|
||||
use rustls::ServerConfig;
|
||||
use rustls::pki_types::ServerName;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn fingerprint_of(cert: &CertificateDer<'_>) -> [u8; 32] {
|
||||
Sha256::digest(cert.as_ref()).into()
|
||||
}
|
||||
|
||||
async fn try_handshake(
|
||||
server_config: Arc<ServerConfig>,
|
||||
client_config: Arc<ClientConfig>,
|
||||
) -> std::io::Result<()> {
|
||||
let (client_io, server_io) = tokio::io::duplex(8192);
|
||||
let acceptor = tokio_rustls::TlsAcceptor::from(server_config);
|
||||
let connector = tokio_rustls::TlsConnector::from(client_config);
|
||||
let server_name = ServerName::try_from("deterministic-node")
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
|
||||
let (client, _server) = tokio::join!(
|
||||
connector.connect(server_name, client_io),
|
||||
acceptor.accept(server_io),
|
||||
);
|
||||
client.map(|_| ())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fingerprint_handshake() {
|
||||
let password = "handshake_test_password";
|
||||
let (cert, key) = generate_deterministic_cert(password).unwrap();
|
||||
let fingerprint = fingerprint_of(&cert);
|
||||
|
||||
let server_config = Arc::new(
|
||||
ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(vec![cert], key)
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
// 正例:指纹匹配且服务端持有对应私钥。
|
||||
// 修复前 verify_tls13_signature 无条件放行,握手必然成功;
|
||||
// 修复后走真实验签,只有实现正确才能握手成功。
|
||||
let client_config = Arc::new(
|
||||
CertValidationMode::VerifyFingerprint(fingerprint)
|
||||
.create_tls_client_config()
|
||||
.unwrap(),
|
||||
);
|
||||
try_handshake(server_config.clone(), client_config)
|
||||
.await
|
||||
.expect("handshake with matching fingerprint should succeed");
|
||||
|
||||
// 反例:指纹不匹配(攻击者证书),握手必须失败
|
||||
let wrong_fingerprint = [0xABu8; 32];
|
||||
let client_config = Arc::new(
|
||||
CertValidationMode::VerifyFingerprint(wrong_fingerprint)
|
||||
.create_tls_client_config()
|
||||
.unwrap(),
|
||||
);
|
||||
assert!(
|
||||
try_handshake(server_config, client_config).await.is_err(),
|
||||
"handshake with mismatched fingerprint should fail"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,134 @@
|
||||
use crate::context::NetworkAddr;
|
||||
use crate::ethernet::strip_ipv4;
|
||||
use crate::nat::internal_nat::InternalNatInbound;
|
||||
use crate::protocol::transmission::TransmissionBytes;
|
||||
use crate::tun::TunDataInbound;
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum EnhancedTunInbound {
|
||||
Tun(TunDataInbound),
|
||||
Tap(TunDataInbound),
|
||||
Nat(InternalNatInbound),
|
||||
}
|
||||
impl EnhancedTunInbound {
|
||||
pub async fn inbound(&self, data: TransmissionBytes, net: &NetworkAddr) -> anyhow::Result<()> {
|
||||
pub async fn inbound(
|
||||
&self,
|
||||
data: TransmissionBytes,
|
||||
net: &NetworkAddr,
|
||||
src_node: Ipv4Addr,
|
||||
ethernet: bool,
|
||||
) -> anyhow::Result<()> {
|
||||
match self {
|
||||
EnhancedTunInbound::Tun(tun) => tun.send(data, net).await,
|
||||
EnhancedTunInbound::Nat(nat) => nat.send(&data, net).await,
|
||||
EnhancedTunInbound::Tun(tun) => {
|
||||
let data = if ethernet {
|
||||
let Some(ip) = strip_ipv4(data) else {
|
||||
return Ok(());
|
||||
};
|
||||
ip
|
||||
} else {
|
||||
data
|
||||
};
|
||||
tun.send_ip(data, net, src_node).await
|
||||
}
|
||||
EnhancedTunInbound::Tap(tap) => {
|
||||
if ethernet {
|
||||
tap.send_frame(data).await
|
||||
} else {
|
||||
tap.send_ip(data, net, src_node).await
|
||||
}
|
||||
}
|
||||
EnhancedTunInbound::Nat(nat) => {
|
||||
let data = if ethernet {
|
||||
let Some(ip) = strip_ipv4(data) else {
|
||||
return Ok(());
|
||||
};
|
||||
ip
|
||||
} else {
|
||||
data
|
||||
};
|
||||
nat.send(&data, net).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::context::config::DeviceMode;
|
||||
use crate::ethernet::{ETHERTYPE_IPV4, parse_frame, wrap_ipv4};
|
||||
use crate::nat::AllowSubnetExternalRoute;
|
||||
use crate::protocol::ip_packet_protocol::HEAD_LENGTH;
|
||||
use crate::tun::{TunDataInbound, tun_channel};
|
||||
|
||||
fn network() -> NetworkAddr {
|
||||
NetworkAddr {
|
||||
gateway: Ipv4Addr::new(10, 26, 0, 1),
|
||||
broadcast: Ipv4Addr::new(10, 26, 0, 255),
|
||||
ip: Ipv4Addr::new(10, 26, 0, 9),
|
||||
prefix_len: 24,
|
||||
}
|
||||
}
|
||||
|
||||
fn ipv4(src: Ipv4Addr, dest: Ipv4Addr) -> TransmissionBytes {
|
||||
let mut packet = TransmissionBytes::with_capacity(HEAD_LENGTH, HEAD_LENGTH + 20);
|
||||
packet.put(&[0u8; 20]).unwrap();
|
||||
packet[0] = 0x45;
|
||||
packet[12..16].copy_from_slice(&src.octets());
|
||||
packet[16..20].copy_from_slice(&dest.octets());
|
||||
packet
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tap_wraps_ip_and_tun_strips_ethernet() {
|
||||
let net = network();
|
||||
let src = Ipv4Addr::new(10, 26, 0, 8);
|
||||
|
||||
let (tap_tx, mut tap_rx) = tun_channel();
|
||||
let tap = EnhancedTunInbound::Tap(TunDataInbound::new(
|
||||
tap_tx,
|
||||
AllowSubnetExternalRoute::new(vec![]),
|
||||
DeviceMode::Tap,
|
||||
));
|
||||
tap.inbound(ipv4(src, net.ip), &net, src, false)
|
||||
.await
|
||||
.unwrap();
|
||||
let frame = tap_rx.receiver.recv().await.unwrap();
|
||||
assert_eq!(
|
||||
parse_frame(frame.as_ref()).unwrap().ethertype,
|
||||
ETHERTYPE_IPV4
|
||||
);
|
||||
|
||||
let (tun_tx, mut tun_rx) = tun_channel();
|
||||
let tun = EnhancedTunInbound::Tun(TunDataInbound::new(
|
||||
tun_tx,
|
||||
AllowSubnetExternalRoute::new(vec![]),
|
||||
DeviceMode::Tun,
|
||||
));
|
||||
let frame = wrap_ipv4(ipv4(src, net.ip), src, &net).unwrap();
|
||||
tun.inbound(frame, &net, src, true).await.unwrap();
|
||||
let packet = tun_rx.receiver.recv().await.unwrap();
|
||||
assert_eq!(packet[0] >> 4, 4);
|
||||
assert_eq!(&packet[16..20], &net.ip.octets());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tap_keeps_arbitrary_ethernet_frame() {
|
||||
let net = network();
|
||||
let src = Ipv4Addr::new(10, 26, 0, 8);
|
||||
let (tap_tx, mut tap_rx) = tun_channel();
|
||||
let tap = EnhancedTunInbound::Tap(TunDataInbound::new(
|
||||
tap_tx,
|
||||
AllowSubnetExternalRoute::new(vec![]),
|
||||
DeviceMode::Tap,
|
||||
));
|
||||
let mut raw = vec![0u8; 32];
|
||||
raw[0..6].copy_from_slice(&[0xff; 6]);
|
||||
raw[12..14].copy_from_slice(&0x88b5u16.to_be_bytes());
|
||||
tap.inbound(raw.as_slice().into(), &net, src, true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(tap_rx.receiver.recv().await.unwrap().as_ref(), raw);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::context::config::DeviceMode;
|
||||
use crate::enhanced_tunnel::outbound::EnhancedOutbound;
|
||||
use crate::protocol::ip_packet_protocol::HEAD_LENGTH;
|
||||
use crate::protocol::transmission::TransmissionBytes;
|
||||
@@ -9,8 +10,10 @@ use std::io;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc::{Receiver, Sender};
|
||||
use tun_rs::AsyncDevice;
|
||||
use tun_rs::async_framed::{Decoder, DeviceFramedRead, DeviceFramedWrite, Encoder};
|
||||
use tun_rs::{AsyncDevice, DeviceBuilder};
|
||||
#[cfg(not(target_os = "android"))]
|
||||
use tun_rs::{DeviceBuilder, Layer};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DeviceIOManager {
|
||||
@@ -19,16 +22,18 @@ pub struct DeviceIOManager {
|
||||
}
|
||||
type DeviceMutex = Arc<tokio::sync::Mutex<(Option<DeviceTask>, Option<(Ipv4Addr, u8)>)>>;
|
||||
pub struct DeviceTask {
|
||||
#[cfg_attr(target_os = "android", allow(dead_code))]
|
||||
device: Arc<AsyncDevice>,
|
||||
task_recv: SubTask,
|
||||
task_send: SubTask,
|
||||
task: SubTask,
|
||||
}
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DeviceConfig {
|
||||
pub device_mode: DeviceMode,
|
||||
pub tun_name: Option<String>,
|
||||
#[cfg(unix)]
|
||||
pub tun_fd: Option<i32>,
|
||||
pub mtu: Option<u16>,
|
||||
pub mac_addr: Option<[u8; 6]>,
|
||||
}
|
||||
|
||||
impl DeviceConfig {
|
||||
@@ -45,6 +50,14 @@ impl DeviceConfig {
|
||||
self.mtu = Some(mtu);
|
||||
self
|
||||
}
|
||||
pub fn set_device_mode(mut self, device_mode: DeviceMode) -> Self {
|
||||
self.device_mode = device_mode;
|
||||
self
|
||||
}
|
||||
pub fn set_mac_addr(mut self, mac_addr: [u8; 6]) -> Self {
|
||||
self.mac_addr = Some(mac_addr);
|
||||
self
|
||||
}
|
||||
}
|
||||
#[derive(Clone)]
|
||||
pub struct TunInbound {
|
||||
@@ -52,7 +65,7 @@ pub struct TunInbound {
|
||||
}
|
||||
|
||||
pub struct TunReceiver {
|
||||
receiver: Receiver<TransmissionBytes>,
|
||||
pub(crate) receiver: Receiver<TransmissionBytes>,
|
||||
}
|
||||
pub fn tun_channel() -> (TunInbound, TunReceiver) {
|
||||
let (sender, receiver) = tokio::sync::mpsc::channel(1024);
|
||||
@@ -69,28 +82,37 @@ impl DeviceIOManager {
|
||||
pub async fn stop_task(&self) {
|
||||
let mut guard = self.device.lock().await;
|
||||
if let Some(dev) = guard.0.take() {
|
||||
dev.task_recv.stop().await;
|
||||
dev.task_send.stop().await;
|
||||
dev.task.stop().await;
|
||||
}
|
||||
}
|
||||
pub async fn start_task(
|
||||
&self,
|
||||
device_config: DeviceConfig,
|
||||
receiver: TunReceiver,
|
||||
enhanced_outbound: EnhancedOutbound,
|
||||
receiver: &mut Option<TunReceiver>,
|
||||
enhanced_outbound: &mut Option<EnhancedOutbound>,
|
||||
) -> anyhow::Result<()> {
|
||||
if receiver.is_none() || enhanced_outbound.is_none() {
|
||||
bail!("device task already started");
|
||||
}
|
||||
self.stop_task().await;
|
||||
// 先执行可能失败的 TUN/TAP 设备创建,成功后才消费 receiver/outbound,
|
||||
// 保证失败时调用方状态完整、可以重试
|
||||
let device_mode = device_config.device_mode;
|
||||
let device = Arc::new(create_device(device_config)?);
|
||||
let receiver = receiver.take().unwrap();
|
||||
let enhanced_outbound = enhanced_outbound.take().unwrap();
|
||||
let task = create(
|
||||
&self.task_group,
|
||||
device_config,
|
||||
device,
|
||||
receiver.receiver,
|
||||
enhanced_outbound,
|
||||
)?;
|
||||
device_mode,
|
||||
);
|
||||
self.device.lock().await.0.replace(task);
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub async fn tun_if_index(&self) -> anyhow::Result<u32> {
|
||||
pub async fn device_if_index(&self) -> anyhow::Result<u32> {
|
||||
let guard = self.device.lock().await;
|
||||
if let Some(v) = &guard.0 {
|
||||
Ok(v.device.if_index()?)
|
||||
@@ -98,10 +120,11 @@ impl DeviceIOManager {
|
||||
bail!("device doesn't exist")
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub async fn set_network(&self, ip: Ipv4Addr, prefix_len: u8) -> anyhow::Result<()> {
|
||||
let mut guard = self.device.lock().await;
|
||||
let Some(dev) = guard.0.as_ref() else {
|
||||
bail!("未启动tun")
|
||||
bail!("虚拟网卡尚未启动")
|
||||
};
|
||||
if let Some(v) = guard.1.as_ref()
|
||||
&& v.0 == ip
|
||||
@@ -117,7 +140,18 @@ impl DeviceIOManager {
|
||||
}
|
||||
}
|
||||
|
||||
fn create_tun(config: DeviceConfig) -> anyhow::Result<AsyncDevice> {
|
||||
fn create_device(config: DeviceConfig) -> anyhow::Result<AsyncDevice> {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
let fd = config
|
||||
.tun_fd
|
||||
.context("Android requires a VpnService TUN fd")?;
|
||||
// SAFETY: The fd comes directly from ParcelFileDescriptor returned by
|
||||
// VpnService.Builder.establish and remains open for the network lifetime.
|
||||
return unsafe { Ok(AsyncDevice::from_fd(fd)?) };
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
#[cfg(unix)]
|
||||
if let Some(fd) = config.tun_fd {
|
||||
// SAFETY: Caller must ensure fd is a valid, open file descriptor for a TUN device.
|
||||
@@ -125,57 +159,88 @@ fn create_tun(config: DeviceConfig) -> anyhow::Result<AsyncDevice> {
|
||||
unsafe { return Ok(AsyncDevice::from_fd(fd)?) }
|
||||
}
|
||||
let mut builder = DeviceBuilder::new();
|
||||
builder = builder.layer(match config.device_mode {
|
||||
DeviceMode::Tap => Layer::L2,
|
||||
DeviceMode::Tun => Layer::L3,
|
||||
DeviceMode::No => bail!("cannot create a device in no mode"),
|
||||
});
|
||||
if let Some(tun_name) = config.tun_name {
|
||||
builder = builder.name(tun_name);
|
||||
}
|
||||
if let Some(mtu) = config.mtu {
|
||||
builder = builder.mtu(mtu);
|
||||
}
|
||||
#[cfg(any(
|
||||
target_os = "windows",
|
||||
target_os = "linux",
|
||||
target_os = "freebsd",
|
||||
target_os = "openbsd",
|
||||
target_os = "macos",
|
||||
target_os = "netbsd"
|
||||
))]
|
||||
if let Some(mac_addr) = config.mac_addr {
|
||||
builder = builder.mac_addr(mac_addr);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
builder = builder.metric(1);
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if config.device_mode == DeviceMode::Tun {
|
||||
builder = builder.offload(true);
|
||||
}
|
||||
let dev = builder.build_async().context("创建tun失败")?;
|
||||
let dev = builder.build_async().with_context(|| {
|
||||
if config.device_mode == DeviceMode::Tap && cfg!(windows) {
|
||||
"创建 TAP 失败;Windows TAP 模式需要预先安装 tap-windows (tap0901) 驱动"
|
||||
} else if config.device_mode == DeviceMode::Tap {
|
||||
"创建 TAP 失败"
|
||||
} else {
|
||||
"创建 TUN 失败"
|
||||
}
|
||||
})?;
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
_ = dev.set_tx_queue_len(1000);
|
||||
}
|
||||
Ok(dev)
|
||||
}
|
||||
}
|
||||
fn create(
|
||||
task_group: &TaskGroup,
|
||||
config: DeviceConfig,
|
||||
device: Arc<AsyncDevice>,
|
||||
receiver: Receiver<TransmissionBytes>,
|
||||
enhanced_outbound: EnhancedOutbound,
|
||||
) -> anyhow::Result<DeviceTask> {
|
||||
let device = Arc::new(create_tun(config)?);
|
||||
|
||||
device_mode: DeviceMode,
|
||||
) -> DeviceTask {
|
||||
let device_framed_read = DeviceFramedRead::new(device.clone(), BytesCodec::new());
|
||||
let device_framed_write = DeviceFramedWrite::new(device.clone(), BytesCodec::new());
|
||||
let outbound_device = device.clone();
|
||||
|
||||
let task_recv = task_group.spawn(async move {
|
||||
if let Err(e) = in_tun_loop(receiver, device_framed_write).await {
|
||||
log::error!("in_tun_loop error: {e:?}")
|
||||
// 读写两个方向合并为一个任务:任一方向结束(出错或设备关闭)即
|
||||
// 通过 select! 取消另一方向,避免单侧失败后另一侧继续运行的半开状态
|
||||
let task = task_group.spawn(async move {
|
||||
tokio::select! {
|
||||
rs = in_device_loop(receiver, device_framed_write) => {
|
||||
if let Err(e) = rs {
|
||||
log::error!("in_device_loop error, stopping out_device_loop: {e:?}");
|
||||
} else {
|
||||
log::warn!("in_device_loop exited, stopping out_device_loop");
|
||||
}
|
||||
}
|
||||
rs = out_device_loop(device_framed_read, outbound_device, enhanced_outbound, device_mode) => {
|
||||
if let Err(e) = rs {
|
||||
log::error!("out_device_loop error, stopping in_device_loop: {e:?}");
|
||||
} else {
|
||||
log::warn!("out_device_loop exited, stopping in_device_loop");
|
||||
}
|
||||
}
|
||||
});
|
||||
let task_send = task_group.spawn(async move {
|
||||
if let Err(e) = out_tun_loop(device_framed_read, enhanced_outbound).await {
|
||||
log::error!("out_tun_loop error: {e:?}");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(DeviceTask {
|
||||
device,
|
||||
task_recv,
|
||||
task_send,
|
||||
})
|
||||
DeviceTask { device, task }
|
||||
}
|
||||
|
||||
async fn in_tun_loop(
|
||||
async fn in_device_loop(
|
||||
mut receiver: Receiver<TransmissionBytes>,
|
||||
mut device_framed_write: DeviceFramedWrite<BytesCodec, Arc<AsyncDevice>>,
|
||||
) -> anyhow::Result<()> {
|
||||
@@ -183,7 +248,7 @@ async fn in_tun_loop(
|
||||
match device_framed_write.send(data).await {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
log::error!("send to tun error: {:?}", e);
|
||||
log::error!("send to virtual device error: {:?}", e);
|
||||
return Err(anyhow::anyhow!(e));
|
||||
}
|
||||
}
|
||||
@@ -191,14 +256,22 @@ async fn in_tun_loop(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn out_tun_loop(
|
||||
async fn out_device_loop(
|
||||
mut device_framed_read: DeviceFramedRead<BytesCodec, Arc<AsyncDevice>>,
|
||||
device: Arc<AsyncDevice>,
|
||||
enhanced_outbound: EnhancedOutbound,
|
||||
device_mode: DeviceMode,
|
||||
) -> anyhow::Result<()> {
|
||||
while let Some(rs) = device_framed_read.next().await {
|
||||
let bytes_mut = rs?;
|
||||
if device_mode == DeviceMode::Tap {
|
||||
if let Some(reply) = enhanced_outbound.ethernet_outbound(bytes_mut).await {
|
||||
device.send(reply.as_ref()).await?;
|
||||
}
|
||||
} else {
|
||||
enhanced_outbound.ipv4_outbound(bytes_mut).await;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +1,40 @@
|
||||
use crate::context::NetworkAddr;
|
||||
use crate::context::config::DeviceMode;
|
||||
use crate::ethernet::wrap_ipv4;
|
||||
use crate::nat::AllowSubnetExternalRoute;
|
||||
use crate::protocol::transmission::TransmissionBytes;
|
||||
use crate::tun::TunInbound;
|
||||
use pnet_packet::ipv4::Ipv4Packet;
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TunDataInbound {
|
||||
allow_subnet: AllowSubnetExternalRoute,
|
||||
tun_inbound: TunInbound,
|
||||
device_mode: DeviceMode,
|
||||
}
|
||||
impl TunDataInbound {
|
||||
pub fn new(tun_inbound: TunInbound, allow_subnet: AllowSubnetExternalRoute) -> Self {
|
||||
pub fn new(
|
||||
tun_inbound: TunInbound,
|
||||
allow_subnet: AllowSubnetExternalRoute,
|
||||
device_mode: DeviceMode,
|
||||
) -> Self {
|
||||
Self {
|
||||
allow_subnet,
|
||||
tun_inbound,
|
||||
device_mode,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TunDataInbound {
|
||||
pub async fn send(&self, data: TransmissionBytes, net: &NetworkAddr) -> anyhow::Result<()> {
|
||||
if data[0] >> 4 != 4 {
|
||||
pub async fn send_ip(
|
||||
&self,
|
||||
data: TransmissionBytes,
|
||||
net: &NetworkAddr,
|
||||
src_node: Ipv4Addr,
|
||||
) -> anyhow::Result<()> {
|
||||
if data.is_empty() || data[0] >> 4 != 4 {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(ipv4) = Ipv4Packet::new(data.as_ref()) else {
|
||||
@@ -33,8 +47,68 @@ impl TunDataInbound {
|
||||
|| dest.is_multicast()
|
||||
|| self.allow_subnet.allow(&dest)
|
||||
{
|
||||
let data = if self.device_mode == DeviceMode::Tap {
|
||||
let Some(frame) = wrap_ipv4(data, src_node, net) else {
|
||||
return Ok(());
|
||||
};
|
||||
frame
|
||||
} else {
|
||||
data
|
||||
};
|
||||
self.tun_inbound.sender.send(data).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_frame(&self, data: TransmissionBytes) -> anyhow::Result<()> {
|
||||
self.tun_inbound.sender.send(data).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::nat::AllowSubnetExternalRoute;
|
||||
use crate::tun::tun_channel;
|
||||
|
||||
fn test_net() -> NetworkAddr {
|
||||
NetworkAddr {
|
||||
gateway: Ipv4Addr::new(10, 26, 0, 1),
|
||||
broadcast: Ipv4Addr::new(10, 26, 0, 255),
|
||||
ip: Ipv4Addr::new(10, 26, 0, 2),
|
||||
prefix_len: 24,
|
||||
}
|
||||
}
|
||||
|
||||
/// 对端构造的零载荷/畸形包必须被静默丢弃,不能 panic 杀死数据面任务
|
||||
#[tokio::test]
|
||||
async fn test_send_empty_or_short_packet_does_not_panic() {
|
||||
let (tun_inbound, _receiver) = tun_channel();
|
||||
let inbound = TunDataInbound::new(
|
||||
tun_inbound,
|
||||
AllowSubnetExternalRoute::new(vec![]),
|
||||
DeviceMode::Tun,
|
||||
);
|
||||
|
||||
// 零载荷包(头部被剥离后为空)
|
||||
inbound
|
||||
.send_ip(
|
||||
TransmissionBytes::zeroed(0),
|
||||
&test_net(),
|
||||
Ipv4Addr::new(10, 26, 0, 3),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 过短的包(不足 IPv4 头)
|
||||
inbound
|
||||
.send_ip(
|
||||
TransmissionBytes::zeroed(3),
|
||||
&test_net(),
|
||||
Ipv4Addr::new(10, 26, 0, 3),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,6 +234,60 @@ impl HybridOutbound {
|
||||
self.traffic_stats.record_tx(dest, len);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn ethernet_ipv4_outbound(
|
||||
&self,
|
||||
net: NetworkAddr,
|
||||
data: TransmissionBytes,
|
||||
mut dest: Ipv4Addr,
|
||||
) -> anyhow::Result<()> {
|
||||
if dest == net.gateway {
|
||||
let Some(ip) = crate::ethernet::strip_ipv4(data) else {
|
||||
return Ok(());
|
||||
};
|
||||
return self.ipv4_gateway_outbound(net, ip).await;
|
||||
}
|
||||
if dest.is_multicast() || dest == net.broadcast || dest.is_broadcast() {
|
||||
return self.ethernet_broadcast_outbound(net, data).await;
|
||||
}
|
||||
if !net.network().contains(&dest) {
|
||||
if let Some(route) = self.external_route.route(&dest) {
|
||||
dest = route;
|
||||
} else {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
self.ethernet_unicast_outbound(net, dest, data).await
|
||||
}
|
||||
|
||||
pub async fn ethernet_unicast_outbound(
|
||||
&self,
|
||||
net: NetworkAddr,
|
||||
dest: Ipv4Addr,
|
||||
mut data: TransmissionBytes,
|
||||
) -> anyhow::Result<()> {
|
||||
let len = data.len() as u64;
|
||||
data.retreat_head(HEAD_LENGTH)?;
|
||||
let mut packet = NetPacket::new(data)?;
|
||||
packet.set_msg_type(MsgType::Turn);
|
||||
packet.set_src_id(net.ip.into());
|
||||
packet.set_dest_id(dest.into());
|
||||
packet.set_ttl(5);
|
||||
packet.set_ethernet_flag(true);
|
||||
let packet = self
|
||||
.packet_compression
|
||||
.compress(packet, self.basic_outbound.encrypt_reserve())?;
|
||||
let packet = if let Some(fec_encoder) = &self.fec_encoder {
|
||||
fec_encoder.encode(packet)?
|
||||
} else {
|
||||
packet
|
||||
};
|
||||
self.basic_outbound
|
||||
.send_encrypted_packet(dest, packet)
|
||||
.await?;
|
||||
self.traffic_stats.record_tx(dest, len);
|
||||
Ok(())
|
||||
}
|
||||
pub async fn ipv4_gateway_outbound(
|
||||
&self,
|
||||
net: NetworkAddr,
|
||||
@@ -279,6 +333,37 @@ impl HybridOutbound {
|
||||
.send_raw_broadcast(exclude_ips, packet_bytes)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn ethernet_broadcast_outbound(
|
||||
&self,
|
||||
net: NetworkAddr,
|
||||
mut data: TransmissionBytes,
|
||||
) -> anyhow::Result<()> {
|
||||
data.retreat_head(HEAD_LENGTH)?;
|
||||
let mut packet = NetPacket::new(data)?;
|
||||
packet.set_msg_type(MsgType::Broadcast);
|
||||
packet.set_src_id(net.ip.into());
|
||||
packet.set_dest_id(Ipv4Addr::BROADCAST.into());
|
||||
packet.set_ttl(5);
|
||||
packet.set_ethernet_flag(true);
|
||||
let mut packet = self
|
||||
.packet_compression
|
||||
.compress(packet, self.basic_outbound.encrypt_reserve())?;
|
||||
self.basic_outbound.encrypt_in_place(&mut packet)?;
|
||||
let packet_bytes = packet.into_bytes();
|
||||
let list = self.server_info.client_online_ips();
|
||||
let exclude_ips = self
|
||||
.basic_outbound
|
||||
.p2p_broadcast_transmission(&list, 16, &packet_bytes);
|
||||
if let Some(exclude_ips) = &exclude_ips
|
||||
&& exclude_ips.len() == list.len()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
self.basic_outbound
|
||||
.send_raw_broadcast(exclude_ips, packet_bytes)
|
||||
.await
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
pub fn has_route(&self, dest: &Ipv4Addr) -> bool {
|
||||
self.basic_outbound.exists_route(dest)
|
||||
|
||||
@@ -201,11 +201,17 @@ impl P2pInboundHandler {
|
||||
let time = i64::from_be_bytes(net_packet.payload()[..8].try_into()?);
|
||||
let now = crate::utils::time::now_ts_ms();
|
||||
if now >= time {
|
||||
// 记录接收并获取丢包率
|
||||
let loss_rate_f64 = self
|
||||
.packet_loss_stats
|
||||
.record_received(ctx.src_ip, route_key);
|
||||
// 转换为万分率
|
||||
let loss_rate = (loss_rate_f64 * 10000.0).round() as u16;
|
||||
|
||||
self.route_table.add_route(
|
||||
ctx.src_ip,
|
||||
Route::from(route_key, metric, (now - time) as _),
|
||||
Route::from_with_loss(route_key, metric, (now - time) as _, loss_rate),
|
||||
);
|
||||
self.packet_loss_stats.record_received(ctx.src_ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -255,6 +261,29 @@ impl P2pInboundHandler {
|
||||
}
|
||||
MsgType::PingTurn => {}
|
||||
MsgType::PongTurn => {}
|
||||
MsgType::RelayProbe => {
|
||||
let metric = ctx.max_ttl - ctx.ttl;
|
||||
self.route_table
|
||||
.add_route_if_absent(ctx.src_ip, Route::from_default_rt(route_key, metric));
|
||||
let mut packet = NetPacket::new(TransmissionBytes::zeroed_size(
|
||||
HEAD_LENGTH,
|
||||
self.packet_crypto.encrypt_reserve(),
|
||||
))?;
|
||||
packet.set_msg_type(MsgType::RelayProbeReply);
|
||||
// 与 RelayProbe 对称:允许中继一次,到达发起方时 curr_ttl 为 0,metric = 2
|
||||
packet.set_ttl(2);
|
||||
packet.set_src_id(ctx.dest_ip.into());
|
||||
packet.set_dest_id(ctx.src_ip.into());
|
||||
self.packet_crypto.encrypt_in_place(&mut packet)?;
|
||||
tunnel
|
||||
.send_to(packet.into_bytes().into_buffer(), route_key.addr())
|
||||
.await?;
|
||||
}
|
||||
MsgType::RelayProbeReply => {
|
||||
let metric = ctx.max_ttl - ctx.ttl;
|
||||
self.route_table
|
||||
.add_route(ctx.src_ip, Route::from_default_rt(route_key, metric));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -68,7 +68,10 @@ impl P2pOutbound {
|
||||
self.route_table.get_route_by_id(id).ok()
|
||||
}
|
||||
pub fn get_p2p_route_by_id(&self, id: &Ipv4Addr) -> Option<Route> {
|
||||
self.route_table.get_route_by_id(id).ok().filter(|v| v.is_direct())
|
||||
self.route_table
|
||||
.get_route_by_id(id)
|
||||
.ok()
|
||||
.filter(|v| v.is_direct())
|
||||
}
|
||||
pub fn exists_route_by_id(&self, id: &Ipv4Addr) -> bool {
|
||||
self.route_table.exists(id)
|
||||
|
||||
@@ -1,30 +1,52 @@
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use rust_p2p_core::route::{RouteKey, DEFAULT_RTT};
|
||||
use rust_p2p_core::route::{DEFAULT_RTT, RouteKey};
|
||||
use std::collections::HashMap;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub struct Route {
|
||||
route_key: RouteKey,
|
||||
metric: u8,
|
||||
rtt: u32,
|
||||
/// 丢包率,万分率(0-10000,10000 表示 100% 丢包)
|
||||
loss_rate: u16,
|
||||
/// 路由评分
|
||||
score: u32,
|
||||
}
|
||||
impl Route {
|
||||
pub fn from(route_key: RouteKey, metric: u8, rtt: u32) -> Self {
|
||||
let is_relay = metric > 1;
|
||||
let score = get_channel_score(rtt, 0, is_relay);
|
||||
Self {
|
||||
route_key,
|
||||
metric,
|
||||
rtt,
|
||||
loss_rate: 0,
|
||||
score,
|
||||
}
|
||||
}
|
||||
pub fn from_with_loss(route_key: RouteKey, metric: u8, rtt: u32, loss_rate: u16) -> Self {
|
||||
let is_relay = metric > 1;
|
||||
let score = get_channel_score(rtt, loss_rate as u32, is_relay);
|
||||
Self {
|
||||
route_key,
|
||||
metric,
|
||||
rtt,
|
||||
loss_rate,
|
||||
score,
|
||||
}
|
||||
}
|
||||
pub fn from_default_rt(route_key: RouteKey, metric: u8) -> Self {
|
||||
let is_relay = metric > 1;
|
||||
let score = get_channel_score(DEFAULT_RTT, 0, is_relay);
|
||||
Self {
|
||||
route_key,
|
||||
metric,
|
||||
rtt: DEFAULT_RTT,
|
||||
loss_rate: 0,
|
||||
score,
|
||||
}
|
||||
}
|
||||
pub fn route_key(&self) -> RouteKey {
|
||||
@@ -40,6 +62,39 @@ impl Route {
|
||||
pub fn metric(&self) -> u8 {
|
||||
self.metric
|
||||
}
|
||||
pub fn loss_rate(&self) -> u16 {
|
||||
self.loss_rate
|
||||
}
|
||||
pub fn score(&self) -> u32 {
|
||||
self.score
|
||||
}
|
||||
}
|
||||
|
||||
/// 计算路由评分
|
||||
///
|
||||
/// # 参数
|
||||
/// - `rtt`: 往返时延(毫秒)
|
||||
/// - `loss_v`: 丢包率(万分率,0-10000)
|
||||
/// - `is_relay`: 是否为中继路由
|
||||
///
|
||||
/// # 返回
|
||||
/// 评分值,越高表示路由质量越好
|
||||
pub fn get_channel_score(rtt: u32, loss_v: u32, is_relay: bool) -> u32 {
|
||||
let rtt = rtt.max(1);
|
||||
let loss_v = loss_v.min(10000);
|
||||
|
||||
// 权重配置
|
||||
let weight = if is_relay { 100 } else { 120 };
|
||||
let k_adj = 10; // 丢包惩罚系数
|
||||
|
||||
// 用 u64 计算避免溢出:分母最大 rtt * 110000,rtt > 约39s 时 u32 溢出
|
||||
// 分子:代表"有效做功"的放大值
|
||||
let numerator = weight as u64 * (10000 - loss_v) as u64 * 100;
|
||||
|
||||
// 分母:代表"链路阻力"
|
||||
let denominator = rtt as u64 * (10000 + loss_v * k_adj) as u64;
|
||||
|
||||
(numerator / denominator).min(u32::MAX as u64) as u32
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -143,18 +198,18 @@ impl RouteTable {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(owner_id) = owner_map.get(route_key) {
|
||||
if owner_id == id {
|
||||
if let Some(owner_id) = owner_map.get(route_key)
|
||||
&& owner_id == id
|
||||
{
|
||||
owner_map.remove(route_key);
|
||||
}
|
||||
}
|
||||
|
||||
time_map.remove(&(*id, *route_key));
|
||||
}
|
||||
|
||||
/// 移除过期的路由
|
||||
pub fn remove_oldest_route(&self, expired_time: Instant) {
|
||||
self.inner.remove_oldest_route(expired_time);
|
||||
pub fn remove_oldest_route(&self, expired_time: Instant) -> Vec<(Ipv4Addr, RouteKey)> {
|
||||
self.inner.remove_oldest_route(expired_time)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,27 +253,27 @@ impl RouteTableInner {
|
||||
|
||||
let list = guard.entry(id).or_insert_with(|| Vec::with_capacity(6));
|
||||
|
||||
// 如果路由已存��,更新并重新排序
|
||||
// 如果路由已存在,更新并重新排序
|
||||
if let Some(idx) = list.iter().position(|v| v.route_key() == key) {
|
||||
list[idx] = route;
|
||||
// 向前冒泡(如果 RTT 更小)
|
||||
// 向前冒泡(如果评分更高)
|
||||
let mut i = idx;
|
||||
while i > 0 && list[i].rtt() < list[i - 1].rtt() {
|
||||
while i > 0 && list[i].score() > list[i - 1].score() {
|
||||
list.swap(i, i - 1);
|
||||
i -= 1;
|
||||
}
|
||||
// 向后冒泡(如果 RTT 更大)
|
||||
while i + 1 < list.len() && list[i].rtt() > list[i + 1].rtt() {
|
||||
// 向后冒泡(如果评分更低)
|
||||
while i + 1 < list.len() && list[i].score() < list[i + 1].score() {
|
||||
list.swap(i, i + 1);
|
||||
i += 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 插入新路由,保持按 RTT 排序
|
||||
// 插入新路由,保持按评分降序排序(评分高的在前)
|
||||
let mut pos = list.len();
|
||||
for (i, r) in list.iter().enumerate() {
|
||||
if route.rtt() < r.rtt() {
|
||||
if route.score() > r.score() {
|
||||
pos = i;
|
||||
break;
|
||||
}
|
||||
@@ -226,7 +281,7 @@ impl RouteTableInner {
|
||||
list.insert(pos, route);
|
||||
}
|
||||
|
||||
fn remove_oldest_route(&self, expired_time: Instant) {
|
||||
fn remove_oldest_route(&self, expired_time: Instant) -> Vec<(Ipv4Addr, RouteKey)> {
|
||||
let mut expired_keys = Vec::new();
|
||||
{
|
||||
let mut time_map = self.route_key_time.lock();
|
||||
@@ -241,25 +296,45 @@ impl RouteTableInner {
|
||||
}
|
||||
|
||||
if expired_keys.is_empty() {
|
||||
return;
|
||||
return expired_keys;
|
||||
}
|
||||
|
||||
let mut table = self.route_table.write();
|
||||
let mut owner_map = self.route_key_owner.lock();
|
||||
|
||||
for (id, route_key) in expired_keys {
|
||||
if let Some(list) = table.get_mut(&id) {
|
||||
list.retain(|r| r.route_key() != route_key);
|
||||
for (id, route_key) in &expired_keys {
|
||||
if let Some(list) = table.get_mut(id) {
|
||||
list.retain(|r| r.route_key() != *route_key);
|
||||
if list.is_empty() {
|
||||
table.remove(&id);
|
||||
table.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(owner_id) = owner_map.get(&route_key) {
|
||||
if *owner_id == id {
|
||||
owner_map.remove(&route_key);
|
||||
}
|
||||
if let Some(owner_id) = owner_map.get(route_key)
|
||||
&& *owner_id == *id
|
||||
{
|
||||
owner_map.remove(route_key);
|
||||
}
|
||||
}
|
||||
|
||||
expired_keys
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// rtt 极大(>39s)时分母不能 u32 溢出(debug 构建下溢出会 panic)
|
||||
#[test]
|
||||
fn test_get_channel_score_large_rtt_no_overflow() {
|
||||
let score = get_channel_score(u32::MAX, 0, false);
|
||||
assert_eq!(score, 0);
|
||||
let score = get_channel_score(u32::MAX, 10000, true);
|
||||
assert_eq!(score, 0);
|
||||
// 正常值结果与预期一致:低 rtt 零丢包得分高
|
||||
let good = get_channel_score(10, 0, false);
|
||||
let bad = get_channel_score(1000, 5000, true);
|
||||
assert!(good > bad);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::context::AppState;
|
||||
use rust_p2p_core::nat::{NatInfo, NatType};
|
||||
use rust_p2p_core::socket::LocalInterface;
|
||||
use rust_p2p_core::tunnel::SocketManager;
|
||||
use rust_p2p_core::tunnel::udp::Model;
|
||||
use std::collections::HashMap;
|
||||
@@ -9,19 +10,38 @@ use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
pub async fn my_nat_info(app_context: AppState, socket_manager: SocketManager) {
|
||||
pub async fn my_nat_info(
|
||||
app_context: AppState,
|
||||
socket_manager: SocketManager,
|
||||
default_interface: Option<LocalInterface>,
|
||||
outbound_interface_name: Option<String>,
|
||||
) {
|
||||
loop {
|
||||
my_nat_info_impl(&app_context, &socket_manager).await;
|
||||
my_nat_info_impl(
|
||||
&app_context,
|
||||
&socket_manager,
|
||||
default_interface.as_ref(),
|
||||
outbound_interface_name.as_deref(),
|
||||
)
|
||||
.await;
|
||||
tokio::time::sleep(Duration::from_secs(60 * 30)).await;
|
||||
}
|
||||
}
|
||||
async fn my_nat_info_impl(app_context: &AppState, socket_manager: &SocketManager) {
|
||||
async fn my_nat_info_impl(
|
||||
app_context: &AppState,
|
||||
socket_manager: &SocketManager,
|
||||
default_interface: Option<&LocalInterface>,
|
||||
outbound_interface_name: Option<&str>,
|
||||
) {
|
||||
let network = app_context.network.network();
|
||||
let mut local_ipv4s = Vec::new();
|
||||
let mut local_ipv6 = Vec::new();
|
||||
match getifaddrs::getifaddrs() {
|
||||
Ok(addrs) => {
|
||||
for x in addrs {
|
||||
if outbound_interface_name.is_some_and(|name| x.name != name) {
|
||||
continue;
|
||||
}
|
||||
let Some(ip) = x.address.ip_addr() else {
|
||||
continue;
|
||||
};
|
||||
@@ -67,17 +87,28 @@ async fn my_nat_info_impl(app_context: &AppState, socket_manager: &SocketManager
|
||||
}
|
||||
}
|
||||
log::info!("local_ipv4s: {:?}", local_ipv4s);
|
||||
let local_ipv4 = rust_p2p_core::extend::addr::local_ipv4()
|
||||
let detected = if outbound_interface_name.is_none() {
|
||||
rust_p2p_core::extend::addr::local_ipv4()
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
.map_err(|e| {
|
||||
log::warn!("local ipv4 failed {e:?}");
|
||||
local_ipv4s
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or(Ipv4Addr::UNSPECIFIED)
|
||||
});
|
||||
local_ipv4s = vec![local_ipv4];
|
||||
let mut ipv6 = rust_p2p_core::extend::addr::local_ipv6().await.ok();
|
||||
e
|
||||
})
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let Some((local_ipv4, merged)) = select_local_ipv4(detected, &local_ipv4s) else {
|
||||
log::warn!("未发现可用本机 IPv4 地址,跳过本次 NAT 信息更新");
|
||||
return;
|
||||
};
|
||||
// 保留网卡扫描结果,主地址排在首位
|
||||
local_ipv4s = merged;
|
||||
let mut ipv6 = if outbound_interface_name.is_none() {
|
||||
rust_p2p_core::extend::addr::local_ipv6().await.ok()
|
||||
} else {
|
||||
local_ipv6.first().cloned()
|
||||
};
|
||||
if let Some(addr) = ipv6 {
|
||||
if addr.is_loopback()
|
||||
|| addr.is_unique_local()
|
||||
@@ -90,16 +121,24 @@ async fn my_nat_info_impl(app_context: &AppState, socket_manager: &SocketManager
|
||||
} else {
|
||||
ipv6 = local_ipv6.first().cloned();
|
||||
}
|
||||
let local_udp_ports = socket_manager
|
||||
.udp_socket_manager_as_ref()
|
||||
.unwrap()
|
||||
.local_ports()
|
||||
.unwrap();
|
||||
let Some(udp_mgr) = socket_manager.udp_socket_manager_as_ref() else {
|
||||
log::warn!("udp socket manager 未就绪,跳过本次 NAT 信息更新");
|
||||
return;
|
||||
};
|
||||
let local_udp_ports = match udp_mgr.local_ports() {
|
||||
Ok(ports) => ports,
|
||||
Err(e) => {
|
||||
log::warn!("获取本地 UDP 端口失败: {e:?},跳过本次 NAT 信息更新");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let local_tcp_port = socket_manager
|
||||
.tcp_socket_manager_as_ref()
|
||||
.unwrap()
|
||||
.local_addr()
|
||||
.port();
|
||||
.map(|m| m.local_addr().port())
|
||||
.unwrap_or_else(|| {
|
||||
log::warn!("tcp socket manager 未就绪,local_tcp_port 置 0");
|
||||
0
|
||||
});
|
||||
log::info!(
|
||||
"local_ipv4={local_ipv4},ipv6={ipv6:?},local_udp_ports:{local_udp_ports:?},local_tcp_port:{local_tcp_port:?}"
|
||||
);
|
||||
@@ -123,7 +162,8 @@ async fn my_nat_info_impl(app_context: &AppState, socket_manager: &SocketManager
|
||||
if stun_server.is_empty() {
|
||||
stun_server = default_udp_stun();
|
||||
}
|
||||
let (nat_type, public_ips, port_range) = rust_p2p_core::stun::stun_test_nat(stun_server, None)
|
||||
let (nat_type, public_ips, port_range) =
|
||||
rust_p2p_core::stun::stun_test_nat(stun_server, default_interface)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("stun_test_nat {e:?}");
|
||||
@@ -138,15 +178,26 @@ async fn my_nat_info_impl(app_context: &AppState, socket_manager: &SocketManager
|
||||
NatType::Cone => Model::Low,
|
||||
NatType::Symmetric => Model::High,
|
||||
};
|
||||
if let Err(e) = socket_manager
|
||||
.udp_socket_manager_as_ref()
|
||||
.unwrap()
|
||||
.switch_model(model)
|
||||
{
|
||||
if let Err(e) = udp_mgr.switch_model(model) {
|
||||
log::error!("switch_model error: {e:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 确定本机主 IPv4 并把它合并到地址列表首位。
|
||||
/// detected 为路由探测得到的主地址(可能不可用),scanned 为网卡扫描结果。
|
||||
/// 返回 None 表示没有任何可用地址,调用方应跳过本次发布,避免上报 0.0.0.0。
|
||||
fn select_local_ipv4(
|
||||
detected: Option<Ipv4Addr>,
|
||||
scanned: &[Ipv4Addr],
|
||||
) -> Option<(Ipv4Addr, Vec<Ipv4Addr>)> {
|
||||
let primary = detected
|
||||
.filter(|ip| !ip.is_unspecified())
|
||||
.or_else(|| scanned.first().copied())?;
|
||||
let mut list: Vec<Ipv4Addr> = scanned.iter().copied().filter(|a| *a != primary).collect();
|
||||
list.insert(0, primary);
|
||||
Some((primary, list))
|
||||
}
|
||||
|
||||
pub async fn query_udp_public_addr_loop(app_context: AppState, socket_manager: SocketManager) {
|
||||
let mut udp_stun_servers = app_context.udp_stun();
|
||||
if udp_stun_servers.is_empty() {
|
||||
@@ -187,7 +238,7 @@ pub(crate) async fn query_tcp_public_addr_loop(
|
||||
app_context: AppState,
|
||||
socket_manager: SocketManager,
|
||||
) {
|
||||
use rand::Rng;
|
||||
use rand::RngExt;
|
||||
use rand::seq::SliceRandom;
|
||||
|
||||
let tcp_stun_servers = {
|
||||
@@ -385,3 +436,51 @@ fn default_tcp_stun() -> Vec<String> {
|
||||
"stun.nextcloud.com:443".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ip(s: &str) -> Ipv4Addr {
|
||||
s.parse().unwrap()
|
||||
}
|
||||
|
||||
/// 探测地址有效时:主地址在首位,扫描结果保留
|
||||
#[test]
|
||||
fn test_select_local_ipv4_detected_valid() {
|
||||
let scanned = vec![ip("192.168.1.2"), ip("10.0.0.3")];
|
||||
let (primary, list) = select_local_ipv4(Some(ip("172.16.0.2")), &scanned).unwrap();
|
||||
assert_eq!(primary, ip("172.16.0.2"));
|
||||
assert_eq!(
|
||||
list,
|
||||
vec![ip("172.16.0.2"), ip("192.168.1.2"), ip("10.0.0.3")]
|
||||
);
|
||||
}
|
||||
|
||||
/// 探测失败/0.0.0.0 时:回退到扫描结果首个地址
|
||||
#[test]
|
||||
fn test_select_local_ipv4_fallback_to_scanned() {
|
||||
let scanned = vec![ip("192.168.1.2"), ip("10.0.0.3")];
|
||||
let (primary, list) = select_local_ipv4(None, &scanned).unwrap();
|
||||
assert_eq!(primary, ip("192.168.1.2"));
|
||||
assert_eq!(list, scanned);
|
||||
|
||||
let (primary, _) = select_local_ipv4(Some(Ipv4Addr::UNSPECIFIED), &scanned).unwrap();
|
||||
assert_eq!(primary, ip("192.168.1.2"));
|
||||
}
|
||||
|
||||
/// 探测地址已在扫描结果中时不重复
|
||||
#[test]
|
||||
fn test_select_local_ipv4_no_duplicate() {
|
||||
let scanned = vec![ip("192.168.1.2"), ip("10.0.0.3")];
|
||||
let (_, list) = select_local_ipv4(Some(ip("10.0.0.3")), &scanned).unwrap();
|
||||
assert_eq!(list, vec![ip("10.0.0.3"), ip("192.168.1.2")]);
|
||||
}
|
||||
|
||||
/// 两者皆空:返回 None,调用方跳过发布,不会上报 0.0.0.0
|
||||
#[test]
|
||||
fn test_select_local_ipv4_none_when_empty() {
|
||||
assert!(select_local_ipv4(None, &[]).is_none());
|
||||
assert!(select_local_ipv4(Some(Ipv4Addr::UNSPECIFIED), &[]).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use crate::tunnel_core::p2p::transport::punch::{PunchTaskContext, punch_task};
|
||||
use crate::tunnel_core::server::outbound::ServerOutbound;
|
||||
use crate::utils::task_control::TaskGroup;
|
||||
use rust_p2p_core::punch::Puncher;
|
||||
use rust_p2p_core::socket::LocalInterface;
|
||||
use rust_p2p_core::tunnel::{Tunnel, TunnelDispatcher, new_tunnel_component};
|
||||
use std::net::Ipv4Addr;
|
||||
use std::sync::Arc;
|
||||
@@ -23,14 +24,30 @@ pub async fn init_tunnel(
|
||||
app_state: AppState,
|
||||
tunnel_to_server: ServerOutbound,
|
||||
packet_crypto: PacketCrypto,
|
||||
tunnel_port: Option<u16>,
|
||||
default_interface: Option<LocalInterface>,
|
||||
outbound_interface_name: Option<String>,
|
||||
) -> anyhow::Result<(Puncher, P2pOutbound, P2pTask)> {
|
||||
let udp_config = rust_p2p_core::tunnel::config::UdpTunnelConfig::default()
|
||||
let tunnel_port = tunnel_port.unwrap_or(0);
|
||||
let mut udp_config = rust_p2p_core::tunnel::config::UdpTunnelConfig::default()
|
||||
.set_main_udp_count(2)
|
||||
.set_sub_udp_count(82);
|
||||
let tcp_config = rust_p2p_core::tunnel::config::TcpTunnelConfig::new(Box::new(
|
||||
.set_sub_udp_count(82)
|
||||
.set_simple_udp_port(tunnel_port);
|
||||
let mut tcp_config = rust_p2p_core::tunnel::config::TcpTunnelConfig::new(Box::new(
|
||||
rust_p2p_core::tunnel::tcp::LengthPrefixedInitCodec,
|
||||
))
|
||||
.set_tcp_multiplexing_limit(2);
|
||||
.set_tcp_multiplexing_limit(2)
|
||||
.set_tcp_port(tunnel_port);
|
||||
if let Some(interface) = default_interface.clone() {
|
||||
// rust-p2p-core 当前的接口绑定实现针对 IPv4;指定出口网卡时关闭
|
||||
// 未绑定的 IPv6 Socket,避免流量绕过所选网卡。
|
||||
udp_config = udp_config
|
||||
.set_default_interface(interface.clone())
|
||||
.set_use_v6(false);
|
||||
tcp_config = tcp_config
|
||||
.set_default_interface(interface)
|
||||
.set_use_v6(false);
|
||||
}
|
||||
let config = rust_p2p_core::tunnel::config::TunnelConfig::empty()
|
||||
.set_udp_tunnel_config(udp_config)
|
||||
.set_tcp_tunnel_config(tcp_config);
|
||||
@@ -44,6 +61,8 @@ pub async fn init_tunnel(
|
||||
task_group.spawn(my_nat_info(
|
||||
app_state.clone(),
|
||||
tunnel_dispatcher.socket_manager(),
|
||||
default_interface,
|
||||
outbound_interface_name,
|
||||
));
|
||||
let manager = tunnel_dispatcher.socket_manager();
|
||||
task_group.spawn(query_udp_public_addr_loop(
|
||||
@@ -52,7 +71,10 @@ pub async fn init_tunnel(
|
||||
));
|
||||
task_group.spawn(query_tcp_public_addr_loop(app_state.clone(), manager));
|
||||
|
||||
task_group.spawn(route_timeout_task(route_table.clone()));
|
||||
task_group.spawn(route_timeout_task(
|
||||
route_table.clone(),
|
||||
app_state.packet_loss_stats.clone(),
|
||||
));
|
||||
let app_state_for_punch = app_state.clone();
|
||||
let punch_ctx = PunchTaskContext {
|
||||
network: app_state.network.clone(),
|
||||
@@ -129,23 +151,23 @@ pub async fn ping_all(
|
||||
ping.set_dest_id(id.into());
|
||||
ping.set_payload(&crate::utils::time::now_ts_ms().to_be_bytes())
|
||||
.unwrap();
|
||||
if socket_manager
|
||||
.send_to(ping, &route.route_key())
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
packet_loss_stats.record_sent(id);
|
||||
let route_key = route.route_key();
|
||||
if socket_manager.send_to(ping, &route_key).await.is_ok() {
|
||||
packet_loss_stats.record_sent(id, route_key);
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
pub async fn route_timeout_task(route_table: RouteTable) {
|
||||
pub async fn route_timeout_task(route_table: RouteTable, packet_loss_stats: PacketLossStats) {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
let expired_time = std::time::Instant::now() - Duration::from_secs(10);
|
||||
route_table.remove_oldest_route(expired_time);
|
||||
let removed_keys = route_table.remove_oldest_route(expired_time);
|
||||
if !removed_keys.is_empty() {
|
||||
packet_loss_stats.remove_batch(&removed_keys);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,22 +224,19 @@ pub async fn relay_probe_task(
|
||||
if non_direct_targets.len() <= 10 {
|
||||
non_direct_targets
|
||||
} else {
|
||||
non_direct_targets
|
||||
.choose_multiple(&mut rng, 10)
|
||||
.copied()
|
||||
.collect()
|
||||
non_direct_targets.sample(&mut rng, 10).copied().collect()
|
||||
}
|
||||
};
|
||||
|
||||
let all_routes = route_table.route_table();
|
||||
let mut direct_peers = Vec::new();
|
||||
for (ip, routes) in &all_routes {
|
||||
if let Some(best_route) = routes.first() {
|
||||
if best_route.is_direct() {
|
||||
if let Some(best_route) = routes.first()
|
||||
&& best_route.is_direct()
|
||||
{
|
||||
direct_peers.push((*ip, best_route.route_key()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if direct_peers.is_empty() {
|
||||
log::debug!("No direct peers available for relay probe");
|
||||
@@ -232,30 +251,28 @@ pub async fn relay_probe_task(
|
||||
direct_peers
|
||||
.iter()
|
||||
.filter(|(ip, _)| ip != target_ip)
|
||||
.choose_multiple(&mut rng, max_probes_per_target)
|
||||
.sample(&mut rng, max_probes_per_target)
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect()
|
||||
};
|
||||
|
||||
for (relay_ip, route_key) in selected_peers {
|
||||
// 构造Ping消息,目标是target_ip,但发送给relay_ip
|
||||
let Ok(mut ping) = NetPacket::new(TransmissionBytes::zeroed_size(
|
||||
HEAD_LENGTH + 8,
|
||||
// 构造RelayProbe消息,目标是target_ip,但发送给relay_ip
|
||||
let Ok(mut probe) = NetPacket::new(TransmissionBytes::zeroed_size(
|
||||
HEAD_LENGTH,
|
||||
socket_manager.encrypt_reserve(),
|
||||
)) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
ping.set_msg_type(MsgType::Ping);
|
||||
ping.set_ttl(2); // TTL设为2,允许中继一次
|
||||
ping.set_src_id(src.into());
|
||||
ping.set_dest_id((*target_ip).into());
|
||||
ping.set_payload(&crate::utils::time::now_ts_ms().to_be_bytes())
|
||||
.unwrap();
|
||||
probe.set_msg_type(MsgType::RelayProbe);
|
||||
probe.set_ttl(2); // TTL设为2,允许中继一次
|
||||
probe.set_src_id(src.into());
|
||||
probe.set_dest_id((*target_ip).into());
|
||||
|
||||
// 发送给已打洞的客户端,让它中继到目标
|
||||
if let Err(e) = socket_manager.send_to(ping, &route_key).await {
|
||||
if let Err(e) = socket_manager.send_to(probe, &route_key).await {
|
||||
log::debug!(
|
||||
"Failed to send relay probe to {} for target {}: {:?}",
|
||||
relay_ip,
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::crypto::PacketCrypto;
|
||||
use crate::enhanced_tunnel::inbound::EnhancedInbound;
|
||||
use crate::fec::FecDecoder;
|
||||
use crate::protocol::control_message::{
|
||||
ConfirmRegResponseMsg, RegResponseMsg, RegistrationMode, RequestMessage, ResponseMessage,
|
||||
ConfirmRegResponseMsg, RegistrationMode, RequestMessage, ResponseMessage,
|
||||
};
|
||||
use crate::tunnel_core::p2p::transport::punch::NatPuncher;
|
||||
use crate::tunnel_core::server::inbound::ServerTurnInboundHandler;
|
||||
@@ -47,13 +47,14 @@ pub(crate) fn create_server_tunnel(
|
||||
app_state: AppState,
|
||||
config: &Config,
|
||||
packet_crypto: PacketCrypto,
|
||||
default_interface: Option<rust_p2p_core::socket::LocalInterface>,
|
||||
) -> (Vec<ServerTurnManager>, ServerOutbound, ServerRPC) {
|
||||
let mut rpc_notifier: HashMap<u32, RpcNotifier> = HashMap::new();
|
||||
let mut sender_map: HashMap<u32, Sender<(Bytes, Instant)>> = HashMap::new();
|
||||
let mut server_manager_list = Vec::with_capacity(config.server_addr.len());
|
||||
let mut server_addr_list = Vec::with_capacity(config.server_addr.len());
|
||||
for (index, server_addr) in config.server_addr.iter().enumerate() {
|
||||
let connect_reg_config = config.to_connect_config(index);
|
||||
let connect_reg_config = config.to_connect_config(index, default_interface.clone());
|
||||
|
||||
let server_id = index as u32;
|
||||
|
||||
@@ -117,9 +118,7 @@ impl ServerTurnManager {
|
||||
let request_msg = RequestMessage::Reg(reg_msg);
|
||||
let encoded = request_msg.encode();
|
||||
|
||||
self.transport_client
|
||||
.send(encoded.freeze())
|
||||
.await?;
|
||||
self.transport_client.send(encoded.freeze()).await?;
|
||||
let buf = self
|
||||
.transport_client
|
||||
.next_timeout(Duration::from_secs(10))
|
||||
@@ -164,9 +163,7 @@ impl ServerTurnManager {
|
||||
config: Box<InboundHandlerConfig>,
|
||||
initial_response: NetworkAddr,
|
||||
) {
|
||||
let data_handler =
|
||||
ServerTurnInboundHandler::new(self.server_id, initial_response, config);
|
||||
let task_group_ = task_group.clone();
|
||||
let data_handler = ServerTurnInboundHandler::new(self.server_id, initial_response, config);
|
||||
let Some(mut receiver) = self.receiver.take() else {
|
||||
unreachable!()
|
||||
};
|
||||
@@ -191,7 +188,12 @@ impl ServerTurnManager {
|
||||
|| reg.prefix_len != initial_response.prefix_len
|
||||
|| reg.gateway != initial_response.gateway
|
||||
{
|
||||
log::error!("虚拟网络发生变化");
|
||||
// 该服务器分配的虚拟网络与当前不一致,无法重连,
|
||||
// 只结束本服务器的任务,不影响其他服务器
|
||||
log::error!(
|
||||
"服务器{}虚拟网络发生变化,放弃重连",
|
||||
self.config.server_addr
|
||||
);
|
||||
break;
|
||||
}
|
||||
// 保存服务器版本
|
||||
@@ -200,22 +202,23 @@ impl ServerTurnManager {
|
||||
}
|
||||
}
|
||||
ResponseMessage::Error(e) => {
|
||||
log::error!("注册失败 {e:?}");
|
||||
break;
|
||||
// 单台服务器注册失败只影响本服务器的重连,
|
||||
// 退避后重试,不能拖垮整个任务组
|
||||
log::error!("注册失败 {e:?},5秒后重试");
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
continue;
|
||||
}
|
||||
_ => {
|
||||
log::error!("错误的注册消息");
|
||||
break;
|
||||
log::error!("错误的注册消息,5秒后重试");
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
log::info!("已连接服务器:{}", self.config.server_addr);
|
||||
data_handler.handle_connected();
|
||||
|
||||
if let Err(e) = self
|
||||
.data_handle_loop(&mut receiver, &data_handler)
|
||||
.await
|
||||
{
|
||||
if let Err(e) = self.data_handle_loop(&mut receiver, &data_handler).await {
|
||||
log::error!("Error on data_handle_loop: {:?}", e);
|
||||
}
|
||||
already_connected = false;
|
||||
@@ -223,7 +226,6 @@ impl ServerTurnManager {
|
||||
}
|
||||
self.disconnect();
|
||||
data_handler.handle_disconnected();
|
||||
task_group_.stop();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -270,8 +272,8 @@ impl ServerTurnManager {
|
||||
/// 3. Send confirmation to all servers
|
||||
/// 4. Return the registration response
|
||||
pub async fn coordinated_registration(
|
||||
managers: &mut Vec<ServerTurnManager>,
|
||||
) -> anyhow::Result<RegResponseMsg> {
|
||||
managers: &mut [ServerTurnManager],
|
||||
) -> anyhow::Result<ResponseMessage> {
|
||||
if managers.is_empty() {
|
||||
bail!("No servers to register");
|
||||
}
|
||||
@@ -287,7 +289,10 @@ pub async fn coordinated_registration(
|
||||
|
||||
let ip = match &first_response {
|
||||
ResponseMessage::Reg(reg) => reg.ip,
|
||||
ResponseMessage::Error(e) => bail!("First server registration failed: {}", e.message),
|
||||
ResponseMessage::Error(e) => {
|
||||
log::info!("First server registration failed: {}", e.message);
|
||||
return Ok(first_response);
|
||||
}
|
||||
_ => bail!("Unexpected response from first server"),
|
||||
};
|
||||
log::info!("Got IP {} from first server", ip);
|
||||
@@ -313,7 +318,8 @@ pub async fn coordinated_registration(
|
||||
log::info!("Server {} pre-registered successfully", i + 1);
|
||||
}
|
||||
Ok(ResponseMessage::Error(e)) => {
|
||||
bail!("Server {} registration failed: {}", i + 1, e.message)
|
||||
log::info!("Server {} registration failed: {}", i + 1, e.message);
|
||||
return Ok(ResponseMessage::Error(e.clone()));
|
||||
}
|
||||
Err(e) => bail!("Server {} registration failed: {}", i + 1, e),
|
||||
_ => bail!("Unexpected response from server {}", i + 1),
|
||||
@@ -339,8 +345,5 @@ pub async fn coordinated_registration(
|
||||
|
||||
log::info!("Coordinated registration completed successfully");
|
||||
// Return first server's response (contains IP info)
|
||||
match first_response {
|
||||
ResponseMessage::Reg(reg) => Ok(reg),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
Ok(first_response)
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ impl ServerTurnInboundHandler {
|
||||
}
|
||||
|
||||
pub async fn handle_server_data(
|
||||
& self,
|
||||
&self,
|
||||
transport_client: &mut TransportClient,
|
||||
network_addr: NetworkAddr,
|
||||
data: TransmissionBytes,
|
||||
@@ -254,7 +254,7 @@ impl ServerTurnInboundHandler {
|
||||
net_packet.set_payload(&bytes_mut)?;
|
||||
self.packet_crypto.encrypt_in_place(&mut net_packet)?;
|
||||
transport_client.send_turn(net_packet).await?;
|
||||
}else{
|
||||
} else {
|
||||
log::info!("限制打洞频率")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ use crate::protocol::control_message::{RegRequestMsg, RegistrationMode};
|
||||
use crate::tls::verifier::CertValidationMode;
|
||||
use anyhow::Context;
|
||||
use rand::seq::SliceRandom;
|
||||
use rust_p2p_core::socket::LocalInterface;
|
||||
use std::fmt;
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::str::FromStr;
|
||||
@@ -16,6 +17,7 @@ pub(crate) struct ConnectRegConfig {
|
||||
pub ip: Option<Ipv4Addr>,
|
||||
pub key_sign: Option<String>,
|
||||
pub ip_variable: bool,
|
||||
pub default_interface: Option<LocalInterface>,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ConnectConfig {
|
||||
@@ -23,6 +25,7 @@ pub(crate) struct ConnectConfig {
|
||||
pub server_addr: SocketAddr,
|
||||
pub server_domain: String,
|
||||
pub cert_mode: CertValidationMode,
|
||||
pub default_interface: Option<LocalInterface>,
|
||||
}
|
||||
#[derive(Debug, Copy, Clone, Eq, PartialEq, Default)]
|
||||
pub enum ProtocolType {
|
||||
@@ -110,40 +113,52 @@ impl ConnectRegConfig {
|
||||
let mut txt = crate::utils::dns_query::dns_query_txt(
|
||||
&self.server_addr.address,
|
||||
vec![],
|
||||
&None,
|
||||
&self.default_interface,
|
||||
)
|
||||
.await?;
|
||||
txt.shuffle(&mut rand::rng());
|
||||
let x = txt.first().context("DNS query failed")?;
|
||||
let x = x.to_lowercase();
|
||||
let (protocol_type, domain) = if let Some(v) = x.strip_prefix("udp://") {
|
||||
(ProtocolType::Quic, v)
|
||||
} else if let Some(v) = x.strip_prefix("quic://") {
|
||||
(ProtocolType::Quic, v)
|
||||
} else if let Some(v) = x.strip_prefix("tcp://") {
|
||||
(ProtocolType::TlsTcp, v)
|
||||
} else if let Some(v) = x.strip_prefix("ws://") {
|
||||
(ProtocolType::TlsTcp, v)
|
||||
} else if let Some(v) = x.strip_prefix("wss://") {
|
||||
(ProtocolType::TlsTcp, v)
|
||||
} else {
|
||||
(ProtocolType::TlsTcp, x.as_str())
|
||||
};
|
||||
let (protocol_type, domain) = parse_dynamic_txt(&x);
|
||||
(protocol_type, domain.to_owned())
|
||||
}
|
||||
v => (v, self.server_addr.address.to_string()),
|
||||
};
|
||||
let server_addr =
|
||||
crate::utils::dns_query::dns_query_one(&server_domain, &vec![], &None).await?;
|
||||
let server_addr = crate::utils::dns_query::dns_query_one(
|
||||
&server_domain,
|
||||
&vec![],
|
||||
&self.default_interface,
|
||||
)
|
||||
.await?;
|
||||
let server_domain = strip_port(&server_domain).to_owned();
|
||||
Ok(ConnectConfig {
|
||||
protocol_type,
|
||||
server_addr,
|
||||
server_domain,
|
||||
cert_mode: self.cert_mode.clone(),
|
||||
default_interface: self.default_interface.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
/// 解析动态 DNS TXT 记录中的协议前缀。
|
||||
/// 注意 wss:// 必须映射到 Wss(此前错映射为 TlsTcp,导致动态发现模式下
|
||||
/// WSS 实际不可用)。
|
||||
fn parse_dynamic_txt(txt: &str) -> (ProtocolType, &str) {
|
||||
if let Some(v) = txt.strip_prefix("udp://") {
|
||||
(ProtocolType::Quic, v)
|
||||
} else if let Some(v) = txt.strip_prefix("quic://") {
|
||||
(ProtocolType::Quic, v)
|
||||
} else if let Some(v) = txt.strip_prefix("tcp://") {
|
||||
(ProtocolType::TlsTcp, v)
|
||||
} else if let Some(v) = txt.strip_prefix("ws://") {
|
||||
(ProtocolType::TlsTcp, v)
|
||||
} else if let Some(v) = txt.strip_prefix("wss://") {
|
||||
(ProtocolType::Wss, v)
|
||||
} else {
|
||||
(ProtocolType::TlsTcp, txt)
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_port(addr: &str) -> &str {
|
||||
if let Some(stripped) = addr.strip_prefix('[')
|
||||
&& let Some(pos) = stripped.find(']')
|
||||
@@ -171,3 +186,38 @@ impl ConnectConfig {
|
||||
&self.server_domain
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 动态 DNS TXT 记录中的 wss:// 必须映射为 Wss,
|
||||
/// 其余前缀维持原有映射不变
|
||||
#[test]
|
||||
fn test_parse_dynamic_txt() {
|
||||
assert_eq!(
|
||||
parse_dynamic_txt("wss://example.com:443"),
|
||||
(ProtocolType::Wss, "example.com:443")
|
||||
);
|
||||
assert_eq!(
|
||||
parse_dynamic_txt("quic://example.com:29872"),
|
||||
(ProtocolType::Quic, "example.com:29872")
|
||||
);
|
||||
assert_eq!(
|
||||
parse_dynamic_txt("udp://example.com:29872"),
|
||||
(ProtocolType::Quic, "example.com:29872")
|
||||
);
|
||||
assert_eq!(
|
||||
parse_dynamic_txt("tcp://example.com:29872"),
|
||||
(ProtocolType::TlsTcp, "example.com:29872")
|
||||
);
|
||||
assert_eq!(
|
||||
parse_dynamic_txt("ws://example.com:80"),
|
||||
(ProtocolType::TlsTcp, "example.com:80")
|
||||
);
|
||||
assert_eq!(
|
||||
parse_dynamic_txt("example.com:29872"),
|
||||
(ProtocolType::TlsTcp, "example.com:29872")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::tunnel_core::server::transport::config::ConnectConfig;
|
||||
use anyhow::{Context, bail};
|
||||
use bytes::Bytes;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use quinn::{ClientConfig, Endpoint, RecvStream, SendStream};
|
||||
use quinn::{ClientConfig, Endpoint, RecvStream, SendStream, TokioRuntime};
|
||||
use std::sync::Arc;
|
||||
use tokio_util::codec::{FramedRead, FramedWrite, LengthDelimitedCodec};
|
||||
|
||||
@@ -56,14 +56,15 @@ pub async fn connect_quic(
|
||||
let server_addr = config.server_addr();
|
||||
let server_name = config.server_name();
|
||||
let quic_config = create_client_config(&config.cert_mode)?;
|
||||
let mut endpoint = match Endpoint::client((std::net::Ipv6Addr::UNSPECIFIED, 0).into()) {
|
||||
Ok(endpoint) => endpoint,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to create QUIC endpoint: {}", e);
|
||||
Endpoint::client((std::net::Ipv4Addr::UNSPECIFIED, 0).into())
|
||||
.context("Failed to create QUIC endpoint")?
|
||||
}
|
||||
let bind_addr = if server_addr.is_ipv4() {
|
||||
(std::net::Ipv4Addr::UNSPECIFIED, 0).into()
|
||||
} else {
|
||||
(std::net::Ipv6Addr::UNSPECIFIED, 0).into()
|
||||
};
|
||||
let socket = crate::utils::socket::bind_udp(bind_addr, config.default_interface.as_ref())?;
|
||||
let socket = socket.into_std()?;
|
||||
let mut endpoint = Endpoint::new(Default::default(), None, socket, Arc::new(TokioRuntime))
|
||||
.context("Failed to create QUIC endpoint")?;
|
||||
|
||||
endpoint.set_default_client_config(quic_config);
|
||||
let connection = endpoint
|
||||
|
||||
@@ -58,7 +58,8 @@ pub async fn connect_tls_tcp(
|
||||
let rustls_config = config.cert_mode.create_tls_client_config()?;
|
||||
let connector = TlsConnector::from(Arc::new(rustls_config));
|
||||
|
||||
let tcp_stream = TcpStream::connect(server_addr)
|
||||
let tcp_stream =
|
||||
crate::utils::socket::connect_tcp(server_addr, config.default_interface.as_ref())
|
||||
.await
|
||||
.context("Failed to establish underlying TCP connection")?;
|
||||
if let Err(e) = tcp_stream.set_nodelay(true) {
|
||||
|
||||
@@ -71,7 +71,8 @@ pub async fn connect_wss(config: &ConnectConfig) -> anyhow::Result<WssStream> {
|
||||
let rustls_config = config.cert_mode.create_tls_client_config()?;
|
||||
let connector = TlsConnector::from(Arc::new(rustls_config));
|
||||
|
||||
let tcp_stream = TcpStream::connect(server_addr)
|
||||
let tcp_stream =
|
||||
crate::utils::socket::connect_tcp(server_addr, config.default_interface.as_ref())
|
||||
.await
|
||||
.context("Failed to establish underlying TCP connection")?;
|
||||
if let Err(e) = tcp_stream.set_nodelay(true) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use anyhow::Context;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
pub fn get_device_id() -> anyhow::Result<String> {
|
||||
#[cfg(not(target_os = "android"))]
|
||||
match machine_uid::get() {
|
||||
Ok(id) => return Ok(id),
|
||||
Err(e) => {
|
||||
@@ -11,10 +12,21 @@ pub fn get_device_id() -> anyhow::Result<String> {
|
||||
|
||||
get_fallback_id()
|
||||
}
|
||||
fn get_fallback_id() -> anyhow::Result<String> {
|
||||
let path = Path::new("device_id");
|
||||
|
||||
if let Ok(content) = fs::read_to_string(path) {
|
||||
/// fallback 设备 ID 文件路径:锚定可执行文件所在目录。
|
||||
/// 相对路径会随进程 CWD 变化,换目录启动就会读写另一个文件,
|
||||
/// 导致设备 ID 漂移
|
||||
fn fallback_id_path() -> PathBuf {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|dir| dir.join("device_id")))
|
||||
.unwrap_or_else(|| PathBuf::from("device_id"))
|
||||
}
|
||||
|
||||
fn get_fallback_id() -> anyhow::Result<String> {
|
||||
let path = fallback_id_path();
|
||||
|
||||
if let Ok(content) = fs::read_to_string(&path) {
|
||||
let id = content.trim();
|
||||
if !id.is_empty() {
|
||||
return Ok(id.to_string());
|
||||
@@ -23,7 +35,20 @@ fn get_fallback_id() -> anyhow::Result<String> {
|
||||
|
||||
let new_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
fs::write(path, &new_id).context("Failed to write device_id file")?;
|
||||
fs::write(&path, &new_id).context("Failed to write device_id file")?;
|
||||
|
||||
Ok(new_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 路径必须锚定到可执行文件目录(绝对路径),不能是随 CWD 漂移的相对路径
|
||||
#[test]
|
||||
fn test_fallback_id_path_is_absolute() {
|
||||
let path = fallback_id_path();
|
||||
assert!(path.is_absolute(), "path should be absolute: {path:?}");
|
||||
assert_eq!(path.file_name().unwrap(), "device_id");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,10 @@ pub async fn dns_query_one(
|
||||
default_interface: &Option<LocalInterface>,
|
||||
) -> anyhow::Result<SocketAddr> {
|
||||
let mut vec = dns_query_all(domain, name_servers, default_interface).await?;
|
||||
if default_interface.is_some() {
|
||||
// 出口网卡绑定目前应用于 IPv4 Socket;优先且只使用可绑定的 IPv4 地址。
|
||||
vec.retain(SocketAddr::is_ipv4);
|
||||
}
|
||||
vec.shuffle(&mut rand::rng());
|
||||
vec.pop().context("DNS query failed")
|
||||
}
|
||||
@@ -121,6 +125,18 @@ pub async fn dns_query_all(
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验域名格式:label 非空且不超过 63 字节,全长不超过 253 字节。
|
||||
/// dns-parser 的 add_question 对非法 label 直接 assert panic,
|
||||
/// 必须在调用前拦截
|
||||
fn is_valid_domain(domain: &str) -> bool {
|
||||
let domain = domain.strip_suffix('.').unwrap_or(domain);
|
||||
!domain.is_empty()
|
||||
&& domain.len() <= 253
|
||||
&& domain
|
||||
.split('.')
|
||||
.all(|label| !label.is_empty() && label.len() <= 63)
|
||||
}
|
||||
|
||||
async fn query<'a>(
|
||||
udp: &UdpSocket,
|
||||
domain: &str,
|
||||
@@ -128,9 +144,21 @@ async fn query<'a>(
|
||||
record_type: QueryType,
|
||||
buf: &'a mut [u8],
|
||||
) -> io::Result<Packet<'a>> {
|
||||
if !is_valid_domain(domain) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("invalid domain {domain:?}"),
|
||||
));
|
||||
}
|
||||
let mut builder = Builder::new_query(1, true);
|
||||
builder.add_question(domain, false, record_type, QueryClass::IN);
|
||||
let packet = builder.build().unwrap();
|
||||
// 非法域名(如 label 超长)build 会失败,不能 unwrap panic
|
||||
let packet = builder.build().map_err(|e| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("invalid domain {domain:?}: {e:?}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
udp.connect(name_server).await?;
|
||||
let mut count = 0;
|
||||
@@ -249,3 +277,39 @@ pub async fn aaaa_dns(
|
||||
}
|
||||
Ok(rs)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 非法域名(label 超过 63 字节)必须返回错误而不是 panic
|
||||
#[tokio::test]
|
||||
async fn test_query_invalid_domain_no_panic() {
|
||||
let udp = UdpSocket::bind("0.0.0.0:0").await.unwrap();
|
||||
let mut buf = vec![0u8; 512];
|
||||
let bad_domain = format!("{}.com", "a".repeat(64));
|
||||
let rs = query(
|
||||
&udp,
|
||||
&bad_domain,
|
||||
"127.0.0.1:53".parse().unwrap(),
|
||||
QueryType::A,
|
||||
&mut buf,
|
||||
)
|
||||
.await;
|
||||
let err = rs.expect_err("invalid domain must be rejected");
|
||||
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_valid_domain() {
|
||||
assert!(is_valid_domain("example.com"));
|
||||
assert!(is_valid_domain("a-b_1.example.com"));
|
||||
assert!(is_valid_domain("example.com.")); // FQDN 尾点合法
|
||||
assert!(is_valid_domain(&format!("{}.com", "a".repeat(63))));
|
||||
|
||||
assert!(!is_valid_domain(""));
|
||||
assert!(!is_valid_domain(&format!("{}.com", "a".repeat(64))));
|
||||
assert!(!is_valid_domain("a..b"));
|
||||
assert!(!is_valid_domain(&"a".repeat(254)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod device_id;
|
||||
pub(crate) mod dns_query;
|
||||
pub(crate) mod socket;
|
||||
pub mod task_control;
|
||||
pub(crate) mod time {
|
||||
pub fn now_ts_ms() -> i64 {
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
use anyhow::Context;
|
||||
use rust_p2p_core::socket::LocalInterface;
|
||||
use socket2::{Domain, Protocol, Socket, Type};
|
||||
use std::io;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ResolvedInterface {
|
||||
pub(crate) name: String,
|
||||
pub(crate) socket_interface: LocalInterface,
|
||||
}
|
||||
|
||||
/// 将配置中的网卡名称解析为底层 Socket 所需的接口标识。
|
||||
/// Linux/Android 使用名称绑定,Windows/macOS 使用接口索引。
|
||||
pub(crate) fn resolve_interface(name: Option<&str>) -> anyhow::Result<Option<ResolvedInterface>> {
|
||||
let Some(name) = name.map(str::trim).filter(|name| !name.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let (canonical_name, index) = match getifaddrs::if_nametoindex(name) {
|
||||
Ok(index) => (
|
||||
getifaddrs::if_indextoname(index).unwrap_or_else(|_| name.to_owned()),
|
||||
index,
|
||||
),
|
||||
Err(_) => {
|
||||
let interface = getifaddrs::getifaddrs()
|
||||
.context("读取本机网卡列表失败")?
|
||||
.find(|interface| {
|
||||
if interface.name.eq_ignore_ascii_case(name) {
|
||||
return true;
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
interface.description.eq_ignore_ascii_case(name)
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
false
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("找不到出口网卡 '{name}'"))?;
|
||||
let index = interface
|
||||
.index
|
||||
.ok_or_else(|| anyhow::anyhow!("出口网卡 '{}' 没有可用索引", interface.name))?;
|
||||
(interface.name, index)
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
let interface = LocalInterface::new(canonical_name.clone());
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||
let interface = LocalInterface::new(index);
|
||||
|
||||
// Linux/Android 只用名称,但仍执行 if_nametoindex 来提前校验配置。
|
||||
let _ = index;
|
||||
Ok(Some(ResolvedInterface {
|
||||
name: canonical_name,
|
||||
socket_interface: interface,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) trait SocketTrait {
|
||||
fn set_ip_unicast_if(&self, _interface: &LocalInterface) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
impl SocketTrait for Socket {
|
||||
fn set_ip_unicast_if(&self, interface: &LocalInterface) -> io::Result<()> {
|
||||
use std::os::windows::io::AsRawSocket;
|
||||
use windows_sys::Win32::Networking::WinSock::{
|
||||
IP_UNICAST_IF, IPPROTO_IP, SOCKET_ERROR, htonl, setsockopt,
|
||||
};
|
||||
|
||||
let raw_socket = self.as_raw_socket();
|
||||
let result = unsafe {
|
||||
let best_interface = htonl(interface.index);
|
||||
setsockopt(
|
||||
raw_socket as usize,
|
||||
IPPROTO_IP,
|
||||
IP_UNICAST_IF,
|
||||
&best_interface as *const _ as *const u8,
|
||||
std::mem::size_of_val(&best_interface) as i32,
|
||||
)
|
||||
};
|
||||
if result == SOCKET_ERROR {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
impl SocketTrait for Socket {
|
||||
fn set_ip_unicast_if(&self, interface: &LocalInterface) -> io::Result<()> {
|
||||
self.bind_device(Some(interface.name.as_bytes()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "ios"))]
|
||||
impl SocketTrait for Socket {
|
||||
fn set_ip_unicast_if(&self, interface: &LocalInterface) -> io::Result<()> {
|
||||
self.bind_device_by_index_v4(std::num::NonZeroU32::new(interface.index))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "freebsd")]
|
||||
impl SocketTrait for Socket {}
|
||||
|
||||
pub(crate) fn bind_socket_to_interface(
|
||||
socket: &Socket,
|
||||
interface: Option<&LocalInterface>,
|
||||
is_ipv4: bool,
|
||||
) -> io::Result<()> {
|
||||
if is_ipv4 && let Some(interface) = interface {
|
||||
socket.set_ip_unicast_if(interface)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn bind_udp(
|
||||
addr: SocketAddr,
|
||||
interface: Option<&LocalInterface>,
|
||||
) -> io::Result<tokio::net::UdpSocket> {
|
||||
let socket = rust_p2p_core::socket::bind_udp(addr, interface)?;
|
||||
tokio::net::UdpSocket::from_std(socket.into())
|
||||
}
|
||||
|
||||
pub(crate) async fn connect_tcp(
|
||||
addr: SocketAddr,
|
||||
interface: Option<&LocalInterface>,
|
||||
) -> io::Result<tokio::net::TcpStream> {
|
||||
let socket = Socket::new(Domain::for_address(addr), Type::STREAM, Some(Protocol::TCP))?;
|
||||
bind_socket_to_interface(
|
||||
&socket,
|
||||
interface,
|
||||
addr.is_ipv4() && !addr.ip().is_loopback(),
|
||||
)?;
|
||||
socket.set_nonblocking(true)?;
|
||||
socket.set_tcp_nodelay(true)?;
|
||||
|
||||
match socket.connect(&addr.into()) {
|
||||
Ok(()) => {}
|
||||
Err(ref error) if error.kind() == io::ErrorKind::WouldBlock => {}
|
||||
#[cfg(unix)]
|
||||
Err(ref error) if error.raw_os_error() == Some(libc::EINPROGRESS) => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
|
||||
let stream = tokio::net::TcpStream::from_std(socket.into())?;
|
||||
stream.writable().await?;
|
||||
if let Some(error) = stream.take_error()? {
|
||||
return Err(error);
|
||||
}
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
pub(crate) async fn connect_tcp_resolved<A: tokio::net::ToSocketAddrs>(
|
||||
addr: A,
|
||||
interface: Option<&LocalInterface>,
|
||||
) -> io::Result<tokio::net::TcpStream> {
|
||||
let addrs = tokio::net::lookup_host(addr).await?.collect::<Vec<_>>();
|
||||
let mut last_error = None;
|
||||
for addr in addrs {
|
||||
match connect_tcp(addr, interface).await {
|
||||
Ok(stream) => return Ok(stream),
|
||||
Err(error) => last_error = Some(error),
|
||||
}
|
||||
}
|
||||
Err(last_error.unwrap_or_else(|| io::Error::other("目标地址解析结果为空")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_interface_name_disables_binding() {
|
||||
assert!(resolve_interface(None).unwrap().is_none());
|
||||
assert!(resolve_interface(Some(" ")).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_interface_name_is_rejected() {
|
||||
let name = "vnt-interface-that-must-not-exist";
|
||||
assert!(resolve_interface(Some(name)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_interface_name_resolves() {
|
||||
let interface = getifaddrs::getifaddrs()
|
||||
.unwrap()
|
||||
.find(|interface| interface.index.is_some())
|
||||
.expect("at least one indexed interface");
|
||||
let resolved = resolve_interface(Some(&interface.name)).unwrap().unwrap();
|
||||
assert_eq!(resolved.name, interface.name);
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||
assert_eq!(resolved.socket_interface.index, interface.index.unwrap());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_friendly_name_resolves() {
|
||||
let interface = getifaddrs::getifaddrs()
|
||||
.unwrap()
|
||||
.find(|interface| interface.index.is_some() && !interface.description.is_empty())
|
||||
.expect("at least one described interface");
|
||||
let resolved = resolve_interface(Some(&interface.description))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(resolved.socket_interface.index, interface.index.unwrap());
|
||||
}
|
||||
}
|
||||
@@ -36,12 +36,16 @@ impl TaskGroupInner {
|
||||
return None;
|
||||
}
|
||||
|
||||
let guard = TaskGuard {
|
||||
inner: Arc::downgrade(self),
|
||||
};
|
||||
|
||||
let weak = Arc::downgrade(self);
|
||||
let handle = tokio::spawn(async move {
|
||||
let _guard = guard;
|
||||
// 在任务上下文内获取自身 id 存入 guard;
|
||||
// 不能延迟到 Drop 里调 tokio::task::id():
|
||||
// abort 路径下 future 可能在非任务上下文被销毁(panic),
|
||||
// 在调用方任务上下文被销毁时又会拿到错误的 id 误删条目
|
||||
let _guard = TaskGuard {
|
||||
inner: weak,
|
||||
task_id: tokio::task::id(),
|
||||
};
|
||||
f.await;
|
||||
});
|
||||
|
||||
@@ -107,13 +111,14 @@ impl Drop for TaskGroupInner {
|
||||
|
||||
struct TaskGuard {
|
||||
inner: Weak<TaskGroupInner>,
|
||||
/// 创建时(任务上下文内)获取的自身任务 id
|
||||
task_id: Id,
|
||||
}
|
||||
|
||||
impl Drop for TaskGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(inner) = self.inner.upgrade() {
|
||||
let task_id = tokio::task::id();
|
||||
inner.remove_task(task_id);
|
||||
inner.remove_task(self.task_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,10 +160,14 @@ impl TaskGroup {
|
||||
|
||||
pub async fn wait_all_stopped(&self) {
|
||||
loop {
|
||||
// 先注册等待再检查条件,避免在检查与等待之间丢失唤醒
|
||||
let notified = self.inner.all_stopped_notify.notified();
|
||||
tokio::pin!(notified);
|
||||
notified.as_mut().enable();
|
||||
if self.inner.all_tasks_stopped() {
|
||||
return;
|
||||
}
|
||||
self.inner.all_stopped_notify.notified().await;
|
||||
notified.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -253,3 +262,67 @@ impl Drop for TaskGroupGuard {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 所有任务自然结束后 wait_all_stopped 必须返回。
|
||||
/// 覆盖两个关键点:任务自然耗尽时 remove_task 置 stopped 并唤醒;
|
||||
/// 等待方先注册再检查,不会因竞态错过唤醒而永久挂起。
|
||||
#[tokio::test]
|
||||
async fn test_wait_all_stopped_after_natural_completion() {
|
||||
let manager = TaskGroupManager::new();
|
||||
let (group, _guard) = manager.create_task().unwrap();
|
||||
|
||||
let waiter = {
|
||||
let group = group.clone();
|
||||
tokio::spawn(async move { group.wait_all_stopped().await })
|
||||
};
|
||||
// 让 waiter 先进入等待
|
||||
tokio::task::yield_now().await;
|
||||
|
||||
let _sub = group.spawn(async {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
});
|
||||
|
||||
tokio::time::timeout(std::time::Duration::from_secs(2), waiter)
|
||||
.await
|
||||
.expect("wait_all_stopped should return after all tasks complete")
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// abort 路径:任务被 stop() 终止后,TaskGuard 必须用创建时保存的 id
|
||||
/// 注销自身;若在 Drop 里调 tokio::task::id(),在非任务上下文会 panic,
|
||||
/// 在调用方任务上下文则会误删调用方的条目。
|
||||
#[tokio::test]
|
||||
async fn test_abort_task_keeps_caller_bookkeeping() {
|
||||
let manager = TaskGroupManager::new();
|
||||
let (group, _guard) = manager.create_task().unwrap();
|
||||
|
||||
let victim = group.spawn(async {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
|
||||
});
|
||||
|
||||
// observer 在同组任务内 abort victim,随后挂起等待放行
|
||||
let (done_tx, done_rx) = tokio::sync::oneshot::channel::<()>();
|
||||
let (exit_tx, exit_rx) = tokio::sync::oneshot::channel::<()>();
|
||||
let observer = group.spawn(async move {
|
||||
victim.stop().await;
|
||||
let _ = done_tx.send(());
|
||||
let _ = exit_rx.await;
|
||||
});
|
||||
|
||||
done_rx.await.unwrap();
|
||||
// victim 的 guard 注销不得误删 observer 的条目
|
||||
assert!(
|
||||
observer.is_running(),
|
||||
"aborting victim must not remove the caller's task entry"
|
||||
);
|
||||
|
||||
let _ = exit_tx.send(());
|
||||
tokio::time::timeout(std::time::Duration::from_secs(2), group.wait_all_stopped())
|
||||
.await
|
||||
.expect("wait_all_stopped should return after observer exits");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# VNT Desktop
|
||||
|
||||
基于 Tauri 2 + Vue 3 的 VNT PC 客户端。桌面端直接内置 `vnt-web` 的服务能力,不需要另行启动 `vnt2_web`。
|
||||
|
||||
桌面端与 Web 端共用 `vnt-web/ui/src/` 下的同一套 Vue 应用、路由、状态、组件和样式。`vnt-desktop/src/main.js` 只负责注册 Tauri IPC 桥接,不维护第二套界面代码。
|
||||
|
||||
## 功能
|
||||
|
||||
- 桌面工作台:总览、实例、在线设备、路由和配置管理
|
||||
- 多实例启动、停止、重启及启动日志
|
||||
- 表单 / TOML 双模式配置编辑
|
||||
- 原生窗口、系统托盘、单实例运行
|
||||
- 关闭主窗口时隐藏到托盘,托盘菜单可彻底退出
|
||||
- 深浅主题与 `Ctrl/Cmd + 1~5` 页面快捷键
|
||||
- 响应式布局:桌面侧栏、移动端顶部栏和抽屉导航
|
||||
- 桌面工作台通过 Tauri IPC 直接调用进程内 `vnt-core`,不监听本地 API 端口
|
||||
- 可选 Web 访问:启停、端口、本机/局域网监听范围、访问令牌、打开浏览器
|
||||
- Web API 强制使用 Bearer 令牌鉴权,令牌可在桌面端重新生成
|
||||
- 关于页提供 GitHub 开源地址与更新检查;桌面端可通过 Tauri Updater 下载并安装更新
|
||||
|
||||
桌面数据存放在系统应用数据目录的 `com.vnt.desktop` 下,包括 `vnt_config`、自启动记录、`web_access.toml`、日志及 Windows 下的 `wintun.dll`。
|
||||
|
||||
## 开发
|
||||
|
||||
要求:Rust、Node.js、pnpm,以及 Tauri 2 对应的系统依赖。
|
||||
|
||||
依赖由仓库根目录的 pnpm workspace 统一管理:
|
||||
|
||||
```powershell
|
||||
pnpm install
|
||||
pnpm dev:desktop
|
||||
```
|
||||
|
||||
只验证前端:
|
||||
|
||||
```powershell
|
||||
pnpm build:desktop-ui
|
||||
```
|
||||
|
||||
验证 Rust 桌面模块:
|
||||
|
||||
```powershell
|
||||
cargo check -p vnt-desktop
|
||||
```
|
||||
|
||||
## 打包
|
||||
|
||||
```powershell
|
||||
$env:TAURI_SIGNING_PRIVATE_KEY=Get-Content -Raw -LiteralPath "$HOME\.tauri\vnt-desktop.key"
|
||||
$env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD=""
|
||||
pnpm build:desktop
|
||||
```
|
||||
|
||||
Windows 使用虚拟网卡模式时,可能需要以管理员身份运行;TAP(二层)模式还需要预先安装 `tap-windows`(`tap0901`)驱动,内置的 `wintun.dll` 只用于 TUN(三层)模式。
|
||||
|
||||
### 发布桌面更新
|
||||
|
||||
Tauri Updater 会从以下 GitHub Release 资源检查更新:
|
||||
|
||||
```text
|
||||
https://github.com/vnt-dev/vnt/releases/latest/download/latest.json
|
||||
```
|
||||
|
||||
更新包必须使用与 `tauri.conf.json` 内公钥匹配的私钥签名。当前密钥默认保存在 `~/.tauri/vnt-desktop.key`,请妥善备份并在 CI 中将私钥内容配置为 `TAURI_SIGNING_PRIVATE_KEY`;私钥不得提交到仓库。发布时需将 Tauri 生成的安装包、`.sig` 文件以及对应的 `latest.json` 一并上传到 GitHub Release。
|
||||
|
||||
仓库的 Release 流水线会在推送版本标签后自动构建 Windows 桌面安装包及 updater 元数据。请在 GitHub Actions Secrets 中配置 `TAURI_SIGNING_PRIVATE_KEY`;如果私钥设置了密码,同时配置 `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`。流水线还会将桌面安装包和各平台 VNT 工具包的直接下载链接写入 Release Notes。
|
||||
|
||||
## 应用图标
|
||||
|
||||
图标母版保存在 `vnt-desktop/assets/vnt-icon-master.png`。需要重新生成各平台图标时,在仓库根目录执行:
|
||||
|
||||
```powershell
|
||||
pnpm --filter vnt-desktop tauri icon assets/vnt-icon-master.png --ios-color '#4F46E5'
|
||||
```
|
||||
|
After Width: | Height: | Size: 988 KiB |
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#07111f" />
|
||||
<title>VNT Desktop</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "vnt-desktop",
|
||||
"private": true,
|
||||
"version": "2.0.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"pinia": "catalog:",
|
||||
"vue": "catalog:",
|
||||
"vue-router": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@tauri-apps/cli": "^2.8.4",
|
||||
"@vitejs/plugin-vue": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"vite": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "vnt-desktop"
|
||||
version = "2.0.2"
|
||||
description = "VNT virtual network desktop client"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
name = "vnt_desktop_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.6.3", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2.11.5", features = ["tray-icon"] }
|
||||
tauri-plugin-single-instance = "2.4.3"
|
||||
tauri-plugin-opener = "2"
|
||||
vnt-web = { path = "../../vnt-web" }
|
||||
vnt2 = { path = "../.." }
|
||||
log.workspace = true
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
tokio-util.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
toml.workspace = true
|
||||
anyhow.workspace = true
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "desktop-capability",
|
||||
"description": "VNT Desktop main window permissions",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-maximize",
|
||||
"core:window:allow-unmaximize",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-start-dragging",
|
||||
"opener:allow-open-url",
|
||||
"process:allow-restart",
|
||||
"updater:default"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 192 KiB |
@@ -0,0 +1,324 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tauri::menu::{Menu, MenuItem};
|
||||
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
|
||||
use tauri::{AppHandle, Manager, WindowEvent};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use vnt_web::VntService;
|
||||
|
||||
#[cfg(windows)]
|
||||
mod wintun;
|
||||
|
||||
static EXITING: AtomicBool = AtomicBool::new(false);
|
||||
const WEB_ACCESS_CONFIG: &str = "web_access.toml";
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
struct WebAccessConfig {
|
||||
enabled: bool,
|
||||
port: u16,
|
||||
global: bool,
|
||||
token: String,
|
||||
}
|
||||
|
||||
impl Default for WebAccessConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
port: 19099,
|
||||
global: false,
|
||||
token: vnt_web::generate_access_token(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WebAccessStatus {
|
||||
enabled: bool,
|
||||
running: bool,
|
||||
port: u16,
|
||||
global: bool,
|
||||
token: String,
|
||||
url: String,
|
||||
listen_address: String,
|
||||
}
|
||||
|
||||
struct WebRuntime {
|
||||
config: WebAccessConfig,
|
||||
cancellation: Option<CancellationToken>,
|
||||
handle: Option<tokio::task::JoinHandle<anyhow::Result<()>>>,
|
||||
}
|
||||
|
||||
struct DesktopState {
|
||||
service: VntService,
|
||||
web: tokio::sync::Mutex<WebRuntime>,
|
||||
config_path: PathBuf,
|
||||
}
|
||||
|
||||
fn load_web_config(path: &Path) -> WebAccessConfig {
|
||||
let mut config: WebAccessConfig = std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|text| toml::from_str(&text).ok())
|
||||
.unwrap_or_default();
|
||||
if config.port == 0 {
|
||||
config.port = 19099;
|
||||
}
|
||||
if config.token.len() < 16 {
|
||||
config.token = vnt_web::generate_access_token();
|
||||
}
|
||||
config
|
||||
}
|
||||
|
||||
fn save_web_config(path: &Path, config: &WebAccessConfig) -> anyhow::Result<()> {
|
||||
std::fs::write(path, toml::to_string_pretty(config)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn listen_addr(config: &WebAccessConfig) -> SocketAddr {
|
||||
let ip = if config.global {
|
||||
IpAddr::V4(Ipv4Addr::UNSPECIFIED)
|
||||
} else {
|
||||
IpAddr::V4(Ipv4Addr::LOCALHOST)
|
||||
};
|
||||
SocketAddr::new(ip, config.port)
|
||||
}
|
||||
|
||||
fn lan_ip() -> Option<IpAddr> {
|
||||
let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).ok()?;
|
||||
socket.connect((Ipv4Addr::new(8, 8, 8, 8), 80)).ok()?;
|
||||
Some(socket.local_addr().ok()?.ip())
|
||||
}
|
||||
|
||||
fn access_url(config: &WebAccessConfig) -> String {
|
||||
let host = if config.global {
|
||||
lan_ip().unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
|
||||
} else {
|
||||
IpAddr::V4(Ipv4Addr::LOCALHOST)
|
||||
};
|
||||
format!("http://{}:{}/?token={}", host, config.port, config.token)
|
||||
}
|
||||
|
||||
async fn stop_web(runtime: &mut WebRuntime) {
|
||||
if let Some(cancellation) = runtime.cancellation.take() {
|
||||
cancellation.cancel();
|
||||
}
|
||||
if let Some(handle) = runtime.handle.take() {
|
||||
match handle.await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(error)) => log::warn!("Web service stopped with error: {error:#}"),
|
||||
Err(error) if !error.is_cancelled() => log::warn!("Web service task failed: {error}"),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_web(service: &VntService, runtime: &mut WebRuntime) -> anyhow::Result<()> {
|
||||
if !runtime.config.enabled {
|
||||
return Ok(());
|
||||
}
|
||||
let cancellation = CancellationToken::new();
|
||||
let handle = service
|
||||
.start_http(
|
||||
listen_addr(&runtime.config),
|
||||
runtime.config.token.clone(),
|
||||
cancellation.clone(),
|
||||
)
|
||||
.await?;
|
||||
runtime.cancellation = Some(cancellation);
|
||||
runtime.handle = Some(handle);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn api_request(
|
||||
state: tauri::State<'_, DesktopState>,
|
||||
method: String,
|
||||
path: String,
|
||||
body: Option<String>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
if !path.starts_with("/api/") {
|
||||
return Err("只允许调用 VNT API".to_string());
|
||||
}
|
||||
state
|
||||
.service
|
||||
.request(&method, &path, body)
|
||||
.await
|
||||
.map_err(|error| format!("{error:#}"))
|
||||
}
|
||||
|
||||
fn web_status(runtime: &WebRuntime) -> WebAccessStatus {
|
||||
WebAccessStatus {
|
||||
enabled: runtime.config.enabled,
|
||||
running: runtime
|
||||
.handle
|
||||
.as_ref()
|
||||
.is_some_and(|handle| !handle.is_finished()),
|
||||
port: runtime.config.port,
|
||||
global: runtime.config.global,
|
||||
token: runtime.config.token.clone(),
|
||||
url: access_url(&runtime.config),
|
||||
listen_address: listen_addr(&runtime.config).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn web_access_status(
|
||||
state: tauri::State<'_, DesktopState>,
|
||||
) -> Result<WebAccessStatus, String> {
|
||||
let runtime = state.web.lock().await;
|
||||
Ok(web_status(&runtime))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn update_web_access(
|
||||
state: tauri::State<'_, DesktopState>,
|
||||
config: WebAccessConfig,
|
||||
) -> Result<WebAccessStatus, String> {
|
||||
if config.port == 0 {
|
||||
return Err("监听端口必须在 1-65535 之间".to_string());
|
||||
}
|
||||
if config.token.len() < 16 {
|
||||
return Err("访问令牌至少需要 16 个字符".to_string());
|
||||
}
|
||||
|
||||
let mut runtime = state.web.lock().await;
|
||||
stop_web(&mut runtime).await;
|
||||
let previous = runtime.config.clone();
|
||||
runtime.config = config;
|
||||
if let Err(error) = start_web(&state.service, &mut runtime).await {
|
||||
runtime.config = previous;
|
||||
if let Err(restore_error) = start_web(&state.service, &mut runtime).await {
|
||||
log::error!("Failed to restore Web service: {restore_error:#}");
|
||||
}
|
||||
return Err(format!("无法启动 Web 服务:{error:#}"));
|
||||
}
|
||||
save_web_config(&state.config_path, &runtime.config)
|
||||
.map_err(|error| format!("保存 Web 访问设置失败:{error:#}"))?;
|
||||
Ok(web_status(&runtime))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn generate_web_token() -> String {
|
||||
vnt_web::generate_access_token()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_web_url(app: AppHandle, url: String) -> Result<(), String> {
|
||||
if !url.starts_with("http://") && !url.starts_with("https://") {
|
||||
return Err("只允许打开 HTTP(S) 地址".to_string());
|
||||
}
|
||||
app.opener()
|
||||
.open_url(url, None::<&str>)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn show_main_window(app: &AppHandle) {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_main_window(app: &AppHandle) {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
match window.is_visible() {
|
||||
Ok(true) => {
|
||||
let _ = window.hide();
|
||||
}
|
||||
_ => show_main_window(app),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
api_request,
|
||||
web_access_status,
|
||||
update_web_access,
|
||||
generate_web_token,
|
||||
open_web_url
|
||||
])
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
show_main_window(app);
|
||||
}))
|
||||
.setup(|app| {
|
||||
let data_dir = app.path().app_data_dir()?;
|
||||
std::fs::create_dir_all(&data_dir)?;
|
||||
std::env::set_current_dir(&data_dir)?;
|
||||
vnt2::log::log_init("vnt-desktop");
|
||||
|
||||
#[cfg(windows)]
|
||||
wintun::ensure_wintun(&data_dir)?;
|
||||
|
||||
let config_path = data_dir.join(WEB_ACCESS_CONFIG);
|
||||
let config = load_web_config(&config_path);
|
||||
save_web_config(&config_path, &config)?;
|
||||
let service = tauri::async_runtime::block_on(VntService::new_desktop(None))?;
|
||||
let mut web = WebRuntime {
|
||||
config,
|
||||
cancellation: None,
|
||||
handle: None,
|
||||
};
|
||||
if let Err(error) = tauri::async_runtime::block_on(start_web(&service, &mut web)) {
|
||||
log::error!("Failed to restore Web access service: {error:#}");
|
||||
web.config.enabled = false;
|
||||
save_web_config(&config_path, &web.config)?;
|
||||
}
|
||||
app.manage(DesktopState {
|
||||
service,
|
||||
web: tokio::sync::Mutex::new(web),
|
||||
config_path,
|
||||
});
|
||||
|
||||
let show = MenuItem::with_id(app, "show", "显示主窗口", true, None::<&str>)?;
|
||||
let quit = MenuItem::with_id(app, "quit", "退出 VNT", true, None::<&str>)?;
|
||||
let menu = Menu::with_items(app, &[&show, &quit])?;
|
||||
|
||||
let mut tray = TrayIconBuilder::with_id("vnt-tray")
|
||||
.tooltip("VNT Desktop")
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||
"show" => show_main_window(app),
|
||||
"quit" => {
|
||||
EXITING.store(true, Ordering::SeqCst);
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
toggle_main_window(tray.app_handle());
|
||||
}
|
||||
});
|
||||
if let Some(icon) = app.default_window_icon() {
|
||||
tray = tray.icon(icon.clone());
|
||||
}
|
||||
tray.build(app)?;
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
if let WindowEvent::CloseRequested { api, .. } = event
|
||||
&& !EXITING.load(Ordering::SeqCst)
|
||||
{
|
||||
api.prevent_close();
|
||||
let _ = window.hide();
|
||||
}
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running VNT Desktop");
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
vnt_desktop_lib::run();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
const WINTUN_DLL: &[u8] = include_bytes!("../../../dll/amd64/wintun.dll");
|
||||
#[cfg(target_arch = "x86")]
|
||||
const WINTUN_DLL: &[u8] = include_bytes!("../../../dll/x86/wintun.dll");
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
const WINTUN_DLL: &[u8] = include_bytes!("../../../dll/arm64/wintun.dll");
|
||||
#[cfg(target_arch = "arm")]
|
||||
const WINTUN_DLL: &[u8] = include_bytes!("../../../dll/arm/wintun.dll");
|
||||
|
||||
pub fn ensure_wintun(data_dir: &Path) -> io::Result<()> {
|
||||
let target = data_dir.join("wintun.dll");
|
||||
let current = std::fs::read(&target).unwrap_or_default();
|
||||
if current != WINTUN_DLL {
|
||||
std::fs::write(target, WINTUN_DLL)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "VNT Desktop",
|
||||
"version": "2.0.2",
|
||||
"identifier": "com.vnt.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"devUrl": "http://127.0.0.1:1420",
|
||||
"beforeBuildCommand": "pnpm build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "VNT Desktop",
|
||||
"width": 1180,
|
||||
"height": 760,
|
||||
"minWidth": 820,
|
||||
"minHeight": 600,
|
||||
"center": true,
|
||||
"decorations": true,
|
||||
"resizable": true,
|
||||
"shadow": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: data:; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:* https://api.github.com"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"createUpdaterArtifacts": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/[email protected]",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"category": "Utility",
|
||||
"shortDescription": "轻量、高效的虚拟局域网桌面客户端",
|
||||
"longDescription": "VNT Desktop 用于创建和管理安全、快速的虚拟局域网连接。",
|
||||
"windows": {
|
||||
"wix": { "language": "zh-CN" },
|
||||
"nsis": { "displayLanguageSelector": false }
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEE3MTQzQjc3NTJGQjBGRTEKUldUaEQvdFNkenNVcHdwd0pRNEhDV1lTdkMzTDZLa1V3WGllMDRoMmxBNDdobi8wclN1dmo3cFoK",
|
||||
"endpoints": [
|
||||
"https://github.com/vnt-dev/vnt/releases/latest/download/latest.json"
|
||||
],
|
||||
"windows": {
|
||||
"installMode": "passive"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { relaunch } from "@tauri-apps/plugin-process";
|
||||
import { check } from "@tauri-apps/plugin-updater";
|
||||
|
||||
let pendingUpdate = null;
|
||||
|
||||
globalThis.__VNT_DESKTOP__ = true;
|
||||
globalThis.__VNT_IPC_REQUEST__ = ({ method, path, body }) =>
|
||||
invoke("api_request", { method, path, body });
|
||||
globalThis.__VNT_WEB_ACCESS__ = {
|
||||
status: () => invoke("web_access_status"),
|
||||
update: (config) => invoke("update_web_access", { config }),
|
||||
generateToken: () => invoke("generate_web_token"),
|
||||
openUrl: (url) => invoke("open_web_url", { url }),
|
||||
};
|
||||
globalThis.__VNT_UPDATER__ = {
|
||||
check: async () => {
|
||||
pendingUpdate = await check();
|
||||
if (!pendingUpdate) return null;
|
||||
return {
|
||||
currentVersion: pendingUpdate.currentVersion,
|
||||
version: pendingUpdate.version,
|
||||
date: pendingUpdate.date || "",
|
||||
body: pendingUpdate.body || "",
|
||||
};
|
||||
},
|
||||
downloadAndInstall: async (onProgress) => {
|
||||
if (!pendingUpdate) throw new Error("请先检查更新");
|
||||
let downloaded = 0;
|
||||
let contentLength = 0;
|
||||
await pendingUpdate.downloadAndInstall((event) => {
|
||||
if (event.event === "Started") {
|
||||
contentLength = event.data.contentLength || 0;
|
||||
} else if (event.event === "Progress") {
|
||||
downloaded += event.data.chunkLength;
|
||||
}
|
||||
onProgress?.({ event: event.event, downloaded, contentLength });
|
||||
});
|
||||
await relaunch();
|
||||
},
|
||||
};
|
||||
|
||||
// Tauri 与 Web 端共用同一个 Vue 应用,这里只负责平台初始化。
|
||||
await import("@shared/main.js");
|
||||
@@ -0,0 +1,24 @@
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
const sharedUi = fileURLToPath(new URL("../vnt-web/ui/src", import.meta.url));
|
||||
const desktopRoot = fileURLToPath(new URL(".", import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@shared": sharedUi,
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: "127.0.0.1",
|
||||
fs: { allow: [desktopRoot, sharedUi] },
|
||||
watch: { ignored: ["**/src-tauri/**"] },
|
||||
},
|
||||
clearScreen: false,
|
||||
});
|
||||
@@ -1,20 +1,21 @@
|
||||
[package]
|
||||
name = "vnt-ipc"
|
||||
version = "2.0.0"
|
||||
version = "2.0.2"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
vnt-core = { path = "../vnt-core" }
|
||||
anyhow = "1.0.100"
|
||||
futures = "0.3.31"
|
||||
prost = "0.14.1"
|
||||
tokio = "1.48.0"
|
||||
tokio-util = "0.7.17"
|
||||
log = "0.4.29"
|
||||
vnt-core.workspace = true
|
||||
anyhow.workspace = true
|
||||
futures.workspace = true
|
||||
prost.workspace = true
|
||||
tokio = { workspace = true, features = ["rt", "net", "io-util", "time"] }
|
||||
tokio-util.workspace = true
|
||||
log.workspace = true
|
||||
time.workspace = true
|
||||
console = "0.16.2"
|
||||
time = "0.3.44"
|
||||
cli-table = "0.5.0"
|
||||
|
||||
[build-dependencies]
|
||||
prost-build = "0.14"
|
||||
protoc-bin-vendored = "3"
|
||||
@@ -1,5 +1,17 @@
|
||||
fn main() {
|
||||
let mut config = prost_build::Config::new();
|
||||
|
||||
match protoc_bin_vendored::protoc_bin_path() {
|
||||
Ok(protoc_path) => {
|
||||
config.protoc_executable(protoc_path);
|
||||
}
|
||||
Err(error) => {
|
||||
println!(
|
||||
"cargo:warning=vendored protoc unavailable ({error:?}); falling back to protoc from PATH"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
config.protoc_arg("--experimental_allow_proto3_optional");
|
||||
config
|
||||
.compile_protos(&["proto/local_ipc.proto"], &["proto"])
|
||||
|
||||
@@ -39,7 +39,11 @@ pub async fn run_client(cmd: IpcCmd, port: Option<u16>) -> anyhow::Result<()> {
|
||||
framed
|
||||
.send(IpcRequest { ipc_cmd: Some(cmd) }.encode_to_vec().into())
|
||||
.await?;
|
||||
let response = framed.next().await.context("Unexpected end of stream")??;
|
||||
// 读响应加超时:服务端异常不回复时客户端不能永久挂起
|
||||
let response = tokio::time::timeout(Duration::from_secs(5), framed.next())
|
||||
.await
|
||||
.context("Response timed out")?
|
||||
.context("Unexpected end of stream")??;
|
||||
let response = IpcResponse::decode(response).context("decode response error")?;
|
||||
match response
|
||||
.response_payload
|
||||
|
||||
@@ -8,5 +8,20 @@ const DEFAULT_PORT: u16 = 11233;
|
||||
const PORT_FILE: &str = "PORT";
|
||||
|
||||
fn get_port_file_path() -> std::path::PathBuf {
|
||||
std::path::PathBuf::from(PORT_FILE)
|
||||
// 锚定可执行文件目录,相对路径会随 CWD 变化导致读不到 PORT 文件
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|dir| dir.join(PORT_FILE)))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from(PORT_FILE))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// PORT 文件路径必须锚定到可执行文件目录(绝对路径)
|
||||
#[test]
|
||||
fn test_port_file_path_is_absolute() {
|
||||
let path = super::get_port_file_path();
|
||||
assert!(path.is_absolute(), "path should be absolute: {path:?}");
|
||||
assert_eq!(path.file_name().unwrap(), super::PORT_FILE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use futures::{SinkExt, StreamExt};
|
||||
use prost::Message;
|
||||
use std::fs;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{self};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio_util::codec::{Framed, LengthDelimitedCodec};
|
||||
@@ -19,7 +20,14 @@ use vnt_core::api::VntApi;
|
||||
async fn handle_connection(stream: TcpStream, vnt_api: VntApi) -> anyhow::Result<()> {
|
||||
let mut framed = Framed::new(stream, LengthDelimitedCodec::new());
|
||||
|
||||
if let Some(Ok(message)) = framed.next().await {
|
||||
// 读请求加超时:空闲连接不能永久占用任务
|
||||
let message = match tokio::time::timeout(Duration::from_secs(10), framed.next()).await {
|
||||
Ok(Some(Ok(message))) => message,
|
||||
Ok(Some(Err(e))) => return Err(e.into()),
|
||||
Ok(None) => bail!("connection closed without request"),
|
||||
Err(_) => bail!("read request timed out"),
|
||||
};
|
||||
{
|
||||
let request = IpcRequest::decode(message.as_ref())?;
|
||||
let Some(cmd) = request.ipc_cmd else {
|
||||
bail!("Received an IpcRequest but it was None");
|
||||
@@ -178,8 +186,15 @@ pub async fn run_server(bind_port: Option<u16>, vnt_api: VntApi) -> anyhow::Resu
|
||||
log::info!("IPC Listening on {}", bound_addr);
|
||||
let actual_port = bound_addr.port();
|
||||
|
||||
// 只有默认端口的实例才写 PORT 文件:显式指定端口的实例、以及端口
|
||||
// 冲突退避到随机端口的实例都不写,避免多实例互相覆盖导致客户端
|
||||
// 连错实例;写入失败不影响服务本身
|
||||
if bind_port.is_none() && actual_port == DEFAULT_PORT {
|
||||
let path = get_port_file_path();
|
||||
fs::write(&path, actual_port.to_string())?;
|
||||
if let Err(e) = fs::write(&path, actual_port.to_string()) {
|
||||
log::warn!("write PORT file failed: {e:?}");
|
||||
}
|
||||
}
|
||||
|
||||
loop {
|
||||
let (stream, peer_addr) = listener.accept().await?;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "vnt-jni"
|
||||
version = "2.0.2"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
vnt-core.workspace = true
|
||||
|
||||
jni = "0.21"
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
parking_lot.workspace = true
|
||||
log.workspace = true
|
||||
anyhow.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
ipnet.workspace = true
|
||||
lazy_static = "1.5"
|
||||
hostname.workspace = true
|
||||
@@ -0,0 +1,144 @@
|
||||
import android.net.VpnService;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
import com.vnt.*;
|
||||
|
||||
/**
|
||||
* Android VPN服务示例
|
||||
*
|
||||
* 演示如何正确使用VNT JNI接口:
|
||||
* 1. 创建网络
|
||||
* 2. 注册获取IP/掩码
|
||||
* 3. 用获取的参数建立Android VPN接口
|
||||
* 4. 传入tunFd启动VNT
|
||||
*/
|
||||
public class AndroidVpnExample extends VpnService {
|
||||
|
||||
private VntNetwork network;
|
||||
private ParcelFileDescriptor vpnInterface;
|
||||
|
||||
@Override
|
||||
public int onStartCommand(android.content.Intent intent, int flags, int startId) {
|
||||
try {
|
||||
startVpn();
|
||||
return START_STICKY;
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
stopSelf();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
}
|
||||
|
||||
private void startVpn() throws Exception {
|
||||
// 1. 初始化VNT(全局初始化,只需一次)
|
||||
if (!VntManager.init()) {
|
||||
throw new VntException("Failed to initialize VNT");
|
||||
}
|
||||
|
||||
// 2. 构建配置
|
||||
VntConfig config = new VntConfig.Builder()
|
||||
.addServer("tcp://101.35.230.139:6660")
|
||||
.setNetworkCode("your_network_code")
|
||||
.setPassword("123456")
|
||||
.setDeviceName("AndroidDevice")
|
||||
.setCompress(true)
|
||||
.setMtu(1380)
|
||||
.build();
|
||||
|
||||
// 3. 创建网络实例
|
||||
network = VntManager.createNetwork(config);
|
||||
if (network == null) {
|
||||
throw new VntException("Failed to create network");
|
||||
}
|
||||
|
||||
// 4. 注册网络(连接服务器,获取分配的IP和掩码)
|
||||
RegisterResult result = network.register();
|
||||
System.out.println("Registration successful: " + result);
|
||||
|
||||
// 5. 使用注册返回的IP和掩码,建立Android VPN接口
|
||||
VpnService.Builder builder = new Builder();
|
||||
builder.setMtu(1380);
|
||||
builder.addAddress(result.getIp(), result.getPrefixLen());
|
||||
builder.addRoute("0.0.0.0", 0); // 全局路由
|
||||
builder.setSession("VNT VPN");
|
||||
|
||||
// 建立VPN接口,获取文件描述符
|
||||
vpnInterface = builder.establish();
|
||||
if (vpnInterface == null) {
|
||||
throw new VntException("Failed to establish VPN interface");
|
||||
}
|
||||
|
||||
int tunFd = vpnInterface.getFd();
|
||||
System.out.println("VPN interface established, fd: " + tunFd);
|
||||
|
||||
// 6. 将tunFd传给VNT,启动数据转发
|
||||
network.startTun(tunFd);
|
||||
System.out.println("VNT started successfully!");
|
||||
|
||||
// 7. 获取API用于查询状态
|
||||
VntApi api = network.getApi();
|
||||
|
||||
// 8. 查询网络信息
|
||||
VntApi.NetworkInfo networkInfo = api.getNetwork();
|
||||
System.out.println("Network info: " + networkInfo);
|
||||
|
||||
// 9. 查询NAT信息
|
||||
VntApi.NatInfo natInfo = api.getNatInfo();
|
||||
System.out.println("NAT info: " + natInfo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
|
||||
// 清理资源
|
||||
if (network != null) {
|
||||
network.stop();
|
||||
network = null;
|
||||
}
|
||||
|
||||
if (vpnInterface != null) {
|
||||
try {
|
||||
vpnInterface.close();
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
vpnInterface = null;
|
||||
}
|
||||
|
||||
VntManager.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询客户端列表(可在UI线程定期调用)
|
||||
*/
|
||||
public void queryClients() {
|
||||
if (network == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
VntApi api = network.getApi();
|
||||
for (VntApi.ClientInfo client : api.getClientList()) {
|
||||
System.out.println("Client: " + client);
|
||||
|
||||
// 检查是否直连
|
||||
boolean direct = api.isDirect(client.getIp());
|
||||
System.out.println(" Direct: " + direct);
|
||||
|
||||
// 获取丢包信息
|
||||
VntApi.PacketLossInfo loss = api.getPacketLoss(client.getIp());
|
||||
if (loss != null) {
|
||||
System.out.println(" Packet loss: " + loss);
|
||||
}
|
||||
|
||||
// 获取流量信息
|
||||
VntApi.TrafficInfo traffic = api.getTrafficInfo(client.getIp());
|
||||
if (traffic != null) {
|
||||
System.out.println(" Traffic: " + traffic);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.vnt;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
/**
|
||||
* 注册结果
|
||||
*
|
||||
* 包含服务器分配的IP地址、掩码等信息
|
||||
* 注意:如果能创建此对象,说明注册一定成功了(失败会抛异常)
|
||||
*/
|
||||
public class RegisterResult {
|
||||
|
||||
private final String ip;
|
||||
private final int prefixLen;
|
||||
private final String gateway;
|
||||
private final String broadcast;
|
||||
|
||||
private RegisterResult(String ip, int prefixLen, String gateway, String broadcast) {
|
||||
this.ip = ip;
|
||||
this.prefixLen = prefixLen;
|
||||
this.gateway = gateway;
|
||||
this.broadcast = broadcast;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从JSON字符串解析注册结果
|
||||
* @throws VntException 如果注册失败或解析失败
|
||||
*/
|
||||
static RegisterResult fromJson(String json) throws VntException {
|
||||
try {
|
||||
JSONObject obj = new JSONObject(json);
|
||||
boolean success = obj.getBoolean("success");
|
||||
|
||||
if (success) {
|
||||
return new RegisterResult(
|
||||
obj.getString("ip"),
|
||||
obj.getInt("prefix_len"),
|
||||
obj.getString("gateway"),
|
||||
obj.getString("broadcast")
|
||||
);
|
||||
} else {
|
||||
// 注册失败,抛出异常
|
||||
String error = obj.getString("error");
|
||||
throw new VntException("Registration failed: " + error);
|
||||
}
|
||||
} catch (VntException e) {
|
||||
throw e; // 重新抛出VntException
|
||||
} catch (Exception e) {
|
||||
throw new VntException("Failed to parse register result: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分配的IP地址
|
||||
*/
|
||||
public String getIp() {
|
||||
return ip;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取前缀长度(掩码位数)
|
||||
*/
|
||||
public int getPrefixLen() {
|
||||
return prefixLen;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取网关地址
|
||||
*/
|
||||
public String getGateway() {
|
||||
return gateway;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取广播地址
|
||||
*/
|
||||
public String getBroadcast() {
|
||||
return broadcast;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为CIDR格式字符串(例如:10.0.0.2/24)
|
||||
*/
|
||||
public String toCidr() {
|
||||
return ip + "/" + prefixLen;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "RegisterResult{" +
|
||||
"ip='" + ip + '\'' +
|
||||
", prefixLen=" + prefixLen +
|
||||
", gateway='" + gateway + '\'' +
|
||||
", broadcast='" + broadcast + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
package com.vnt;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* VNT API接口 - 用于查询网络状态和信息
|
||||
*
|
||||
* 通过VntNetwork.getApi()获取实例
|
||||
*/
|
||||
public class VntApi {
|
||||
|
||||
private final long nativeHandle;
|
||||
|
||||
// 包内构造,只能通过VntNetwork创建
|
||||
VntApi(long handle) {
|
||||
this.nativeHandle = handle;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取客户端列表
|
||||
* @return 客户端信息列表
|
||||
*/
|
||||
public List<ClientInfo> getClientList() throws VntException {
|
||||
try {
|
||||
String json = nativeGetClientList(nativeHandle);
|
||||
JSONArray array = new JSONArray(json);
|
||||
List<ClientInfo> clients = new ArrayList<>();
|
||||
|
||||
for (int i = 0; i < array.length(); i++) {
|
||||
JSONObject obj = array.getJSONObject(i);
|
||||
clients.add(new ClientInfo(
|
||||
obj.getString("ip"),
|
||||
obj.getBoolean("online")
|
||||
));
|
||||
}
|
||||
return clients;
|
||||
} catch (Exception e) {
|
||||
throw new VntException("Failed to get client list: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前网络配置
|
||||
* @return 网络信息,未连接返回null
|
||||
*/
|
||||
public NetworkInfo getNetwork() throws VntException {
|
||||
try {
|
||||
String json = nativeGetNetwork(nativeHandle);
|
||||
if ("null".equals(json)) {
|
||||
return null;
|
||||
}
|
||||
JSONObject obj = new JSONObject(json);
|
||||
return new NetworkInfo(
|
||||
obj.getString("ip"),
|
||||
obj.getInt("prefix_len"),
|
||||
obj.getString("gateway"),
|
||||
obj.getString("broadcast")
|
||||
);
|
||||
} catch (Exception e) {
|
||||
throw new VntException("Failed to get network info: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本地NAT信息
|
||||
* @return NAT信息,未检测到返回null
|
||||
*/
|
||||
public NatInfo getNatInfo() throws VntException {
|
||||
try {
|
||||
String json = nativeGetNatInfo(nativeHandle);
|
||||
if ("null".equals(json)) {
|
||||
return null;
|
||||
}
|
||||
return NatInfo.fromJson(json);
|
||||
} catch (Exception e) {
|
||||
throw new VntException("Failed to get NAT info: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取服务器节点列表
|
||||
* @return 服务器信息列表
|
||||
*/
|
||||
public List<ServerInfo> getServerList() throws VntException {
|
||||
try {
|
||||
String json = nativeGetServerList(nativeHandle);
|
||||
JSONArray array = new JSONArray(json);
|
||||
List<ServerInfo> servers = new ArrayList<>();
|
||||
|
||||
for (int i = 0; i < array.length(); i++) {
|
||||
JSONObject obj = array.getJSONObject(i);
|
||||
servers.add(new ServerInfo(
|
||||
obj.getInt("server_id"),
|
||||
obj.getString("server_addr"),
|
||||
obj.getBoolean("connected"),
|
||||
obj.isNull("rtt") ? null : obj.getInt("rtt"),
|
||||
obj.getLong("data_version"),
|
||||
obj.isNull("server_version") ? null : obj.getString("server_version")
|
||||
));
|
||||
}
|
||||
return servers;
|
||||
} catch (Exception e) {
|
||||
throw new VntException("Failed to get server list: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取路由表
|
||||
* @return 路由信息列表
|
||||
*/
|
||||
public List<RouteInfo> getRouteTable() throws VntException {
|
||||
try {
|
||||
String json = nativeGetRouteTable(nativeHandle);
|
||||
JSONArray array = new JSONArray(json);
|
||||
List<RouteInfo> routes = new ArrayList<>();
|
||||
|
||||
for (int i = 0; i < array.length(); i++) {
|
||||
JSONObject obj = array.getJSONObject(i);
|
||||
String ip = obj.getString("ip");
|
||||
JSONArray routesArray = obj.getJSONArray("routes");
|
||||
|
||||
List<RouteDetail> details = new ArrayList<>();
|
||||
for (int j = 0; j < routesArray.length(); j++) {
|
||||
JSONObject route = routesArray.getJSONObject(j);
|
||||
details.add(new RouteDetail(
|
||||
route.getString("route_key"),
|
||||
route.getString("protocol"),
|
||||
route.getInt("metric"),
|
||||
route.getInt("rtt")
|
||||
));
|
||||
}
|
||||
routes.add(new RouteInfo(ip, details));
|
||||
}
|
||||
return routes;
|
||||
} catch (Exception e) {
|
||||
throw new VntException("Failed to get route table: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查目标IP是否直连(P2P)
|
||||
* @param ip 目标IP地址
|
||||
* @return true表示直连,false表示通过服务器中转
|
||||
*/
|
||||
public boolean isDirect(String ip) {
|
||||
return nativeIsDirect(nativeHandle, ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对端NAT信息
|
||||
* @param ip 目标IP地址
|
||||
* @return NAT信息,未知返回null
|
||||
*/
|
||||
public NatInfo getPeerNatInfo(String ip) throws VntException {
|
||||
try {
|
||||
String json = nativeGetPeerNatInfo(nativeHandle, ip);
|
||||
if ("null".equals(json)) {
|
||||
return null;
|
||||
}
|
||||
return NatInfo.fromJson(json);
|
||||
} catch (Exception e) {
|
||||
throw new VntException("Failed to get peer NAT info: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对端丢包信息
|
||||
* @param ip 目标IP地址
|
||||
* @return 丢包信息,未知返回null
|
||||
*/
|
||||
public PacketLossInfo getPacketLoss(String ip) throws VntException {
|
||||
try {
|
||||
String json = nativeGetPacketLoss(nativeHandle, ip);
|
||||
if ("null".equals(json)) {
|
||||
return null;
|
||||
}
|
||||
JSONObject obj = new JSONObject(json);
|
||||
return new PacketLossInfo(
|
||||
obj.getString("ip"),
|
||||
obj.getLong("sent"),
|
||||
obj.getLong("received"),
|
||||
obj.getDouble("loss_rate")
|
||||
);
|
||||
} catch (Exception e) {
|
||||
throw new VntException("Failed to get packet loss: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对端流量统计
|
||||
* @param ip 目标IP地址
|
||||
* @return 流量信息,未知返回null
|
||||
*/
|
||||
public TrafficInfo getTrafficInfo(String ip) throws VntException {
|
||||
try {
|
||||
String json = nativeGetTrafficInfo(nativeHandle, ip);
|
||||
if ("null".equals(json)) {
|
||||
return null;
|
||||
}
|
||||
JSONObject obj = new JSONObject(json);
|
||||
return new TrafficInfo(
|
||||
obj.getString("ip"),
|
||||
obj.getLong("tx_bytes"),
|
||||
obj.getLong("rx_bytes")
|
||||
);
|
||||
} catch (Exception e) {
|
||||
throw new VntException("Failed to get traffic info: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Native 方法 ==========
|
||||
|
||||
private static native String nativeGetClientList(long apiHandle);
|
||||
private static native String nativeGetNetwork(long apiHandle);
|
||||
private static native String nativeGetNatInfo(long apiHandle);
|
||||
private static native String nativeGetServerList(long apiHandle);
|
||||
private static native String nativeGetRouteTable(long apiHandle);
|
||||
private static native boolean nativeIsDirect(long apiHandle, String ip);
|
||||
private static native String nativeGetPeerNatInfo(long apiHandle, String ip);
|
||||
private static native String nativeGetPacketLoss(long apiHandle, String ip);
|
||||
private static native String nativeGetTrafficInfo(long apiHandle, String ip);
|
||||
|
||||
// ========== 数据类 ==========
|
||||
|
||||
public static class ClientInfo {
|
||||
private final String ip;
|
||||
private final boolean online;
|
||||
|
||||
public ClientInfo(String ip, boolean online) {
|
||||
this.ip = ip;
|
||||
this.online = online;
|
||||
}
|
||||
|
||||
public String getIp() { return ip; }
|
||||
public boolean isOnline() { return online; }
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "ClientInfo{ip='" + ip + "', online=" + online + "}";
|
||||
}
|
||||
}
|
||||
|
||||
public static class NetworkInfo {
|
||||
private final String ip;
|
||||
private final int prefixLen;
|
||||
private final String gateway;
|
||||
private final String broadcast;
|
||||
|
||||
public NetworkInfo(String ip, int prefixLen, String gateway, String broadcast) {
|
||||
this.ip = ip;
|
||||
this.prefixLen = prefixLen;
|
||||
this.gateway = gateway;
|
||||
this.broadcast = broadcast;
|
||||
}
|
||||
|
||||
public String getIp() { return ip; }
|
||||
public int getPrefixLen() { return prefixLen; }
|
||||
public String getGateway() { return gateway; }
|
||||
public String getBroadcast() { return broadcast; }
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "NetworkInfo{ip='" + ip + "', prefixLen=" + prefixLen +
|
||||
", gateway='" + gateway + "', broadcast='" + broadcast + "'}";
|
||||
}
|
||||
}
|
||||
|
||||
public static class NatInfo {
|
||||
private final String natType;
|
||||
private final List<String> publicIps;
|
||||
private final String ipv6;
|
||||
|
||||
private NatInfo(String natType, List<String> publicIps, String ipv6) {
|
||||
this.natType = natType;
|
||||
this.publicIps = publicIps;
|
||||
this.ipv6 = ipv6;
|
||||
}
|
||||
|
||||
static NatInfo fromJson(String json) throws Exception {
|
||||
JSONObject obj = new JSONObject(json);
|
||||
JSONArray ipsArray = obj.getJSONArray("public_ips");
|
||||
List<String> publicIps = new ArrayList<>();
|
||||
for (int i = 0; i < ipsArray.length(); i++) {
|
||||
publicIps.add(ipsArray.getString(i));
|
||||
}
|
||||
return new NatInfo(
|
||||
obj.getString("nat_type"),
|
||||
publicIps,
|
||||
obj.isNull("ipv6") ? null : obj.getString("ipv6")
|
||||
);
|
||||
}
|
||||
|
||||
public String getNatType() { return natType; }
|
||||
public List<String> getPublicIps() { return publicIps; }
|
||||
public String getIpv6() { return ipv6; }
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "NatInfo{natType='" + natType + "', publicIps=" + publicIps +
|
||||
", ipv6='" + ipv6 + "'}";
|
||||
}
|
||||
}
|
||||
|
||||
public static class ServerInfo {
|
||||
private final int serverId;
|
||||
private final String serverAddr;
|
||||
private final boolean connected;
|
||||
private final Integer rtt;
|
||||
private final long dataVersion;
|
||||
private final String serverVersion;
|
||||
|
||||
public ServerInfo(int serverId, String serverAddr, boolean connected,
|
||||
Integer rtt, long dataVersion, String serverVersion) {
|
||||
this.serverId = serverId;
|
||||
this.serverAddr = serverAddr;
|
||||
this.connected = connected;
|
||||
this.rtt = rtt;
|
||||
this.dataVersion = dataVersion;
|
||||
this.serverVersion = serverVersion;
|
||||
}
|
||||
|
||||
public int getServerId() { return serverId; }
|
||||
public String getServerAddr() { return serverAddr; }
|
||||
public boolean isConnected() { return connected; }
|
||||
public Integer getRtt() { return rtt; }
|
||||
public long getDataVersion() { return dataVersion; }
|
||||
public String getServerVersion() { return serverVersion; }
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "ServerInfo{serverId=" + serverId + ", serverAddr='" + serverAddr +
|
||||
"', connected=" + connected + ", rtt=" + rtt + "}";
|
||||
}
|
||||
}
|
||||
|
||||
public static class RouteInfo {
|
||||
private final String ip;
|
||||
private final List<RouteDetail> routes;
|
||||
|
||||
public RouteInfo(String ip, List<RouteDetail> routes) {
|
||||
this.ip = ip;
|
||||
this.routes = routes;
|
||||
}
|
||||
|
||||
public String getIp() { return ip; }
|
||||
public List<RouteDetail> getRoutes() { return routes; }
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "RouteInfo{ip='" + ip + "', routes=" + routes + "}";
|
||||
}
|
||||
}
|
||||
|
||||
public static class RouteDetail {
|
||||
private final String routeKey;
|
||||
private final String protocol;
|
||||
private final int metric;
|
||||
private final int rtt;
|
||||
|
||||
public RouteDetail(String routeKey, String protocol, int metric, int rtt) {
|
||||
this.routeKey = routeKey;
|
||||
this.protocol = protocol;
|
||||
this.metric = metric;
|
||||
this.rtt = rtt;
|
||||
}
|
||||
|
||||
public String getRouteKey() { return routeKey; }
|
||||
public String getProtocol() { return protocol; }
|
||||
public int getMetric() { return metric; }
|
||||
public int getRtt() { return rtt; }
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "RouteDetail{routeKey='" + routeKey + "', protocol='" + protocol +
|
||||
"', metric=" + metric + ", rtt=" + rtt + "}";
|
||||
}
|
||||
}
|
||||
|
||||
public static class PacketLossInfo {
|
||||
private final String ip;
|
||||
private final long sent;
|
||||
private final long received;
|
||||
private final double lossRate;
|
||||
|
||||
public PacketLossInfo(String ip, long sent, long received, double lossRate) {
|
||||
this.ip = ip;
|
||||
this.sent = sent;
|
||||
this.received = received;
|
||||
this.lossRate = lossRate;
|
||||
}
|
||||
|
||||
public String getIp() { return ip; }
|
||||
public long getSent() { return sent; }
|
||||
public long getReceived() { return received; }
|
||||
public double getLossRate() { return lossRate; }
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "PacketLossInfo{ip='" + ip + "', sent=" + sent +
|
||||
", received=" + received + ", lossRate=" + lossRate + "}";
|
||||
}
|
||||
}
|
||||
|
||||
public static class TrafficInfo {
|
||||
private final String ip;
|
||||
private final long txBytes;
|
||||
private final long rxBytes;
|
||||
|
||||
public TrafficInfo(String ip, long txBytes, long rxBytes) {
|
||||
this.ip = ip;
|
||||
this.txBytes = txBytes;
|
||||
this.rxBytes = rxBytes;
|
||||
}
|
||||
|
||||
public String getIp() { return ip; }
|
||||
public long getTxBytes() { return txBytes; }
|
||||
public long getRxBytes() { return rxBytes; }
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "TrafficInfo{ip='" + ip + "', txBytes=" + txBytes +
|
||||
", rxBytes=" + rxBytes + "}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package com.vnt;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* VNT网络配置
|
||||
*
|
||||
* 使用Builder模式构建配置
|
||||
*/
|
||||
public class VntConfig {
|
||||
|
||||
private final List<String> servers;
|
||||
private final String networkCode;
|
||||
private final String password;
|
||||
private final String deviceId;
|
||||
private final String deviceName;
|
||||
private final String tunName;
|
||||
private final String outboundInterface;
|
||||
private final String ip;
|
||||
private final String certMode;
|
||||
private final boolean noPunch;
|
||||
private final boolean compress;
|
||||
private final boolean rtx;
|
||||
private final boolean fec;
|
||||
private final boolean noNat;
|
||||
private final String deviceMode;
|
||||
private final Integer mtu;
|
||||
private final boolean allowMapping;
|
||||
private final List<String> portMapping;
|
||||
private final List<String> udpStun;
|
||||
private final List<String> tcpStun;
|
||||
|
||||
private VntConfig(Builder builder) {
|
||||
this.servers = builder.servers;
|
||||
this.networkCode = builder.networkCode;
|
||||
this.password = builder.password;
|
||||
this.deviceId = builder.deviceId;
|
||||
this.deviceName = builder.deviceName;
|
||||
this.tunName = builder.tunName;
|
||||
this.outboundInterface = builder.outboundInterface;
|
||||
this.ip = builder.ip;
|
||||
this.certMode = builder.certMode;
|
||||
this.noPunch = builder.noPunch;
|
||||
this.compress = builder.compress;
|
||||
this.rtx = builder.rtx;
|
||||
this.fec = builder.fec;
|
||||
this.noNat = builder.noNat;
|
||||
this.deviceMode = builder.deviceMode;
|
||||
this.mtu = builder.mtu;
|
||||
this.allowMapping = builder.allowMapping;
|
||||
this.portMapping = builder.portMapping;
|
||||
this.udpStun = builder.udpStun;
|
||||
this.tcpStun = builder.tcpStun;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为JSON字符串
|
||||
*/
|
||||
String toJson() {
|
||||
JSONObject json = new JSONObject();
|
||||
|
||||
// 必填项
|
||||
JSONArray serverArray = new JSONArray();
|
||||
for (String server : servers) {
|
||||
serverArray.put(server);
|
||||
}
|
||||
json.put("server", serverArray);
|
||||
json.put("network_code", networkCode);
|
||||
|
||||
// 可选项
|
||||
if (password != null) json.put("password", password);
|
||||
if (deviceId != null) json.put("device_id", deviceId);
|
||||
if (deviceName != null) json.put("device_name", deviceName);
|
||||
if (tunName != null) json.put("tun_name", tunName);
|
||||
if (outboundInterface != null) json.put("outbound_interface", outboundInterface);
|
||||
if (ip != null) json.put("ip", ip);
|
||||
if (certMode != null) json.put("cert_mode", certMode);
|
||||
if (mtu != null) json.put("mtu", mtu);
|
||||
|
||||
// 布尔值
|
||||
json.put("no_punch", noPunch);
|
||||
json.put("compress", compress);
|
||||
json.put("rtx", rtx);
|
||||
json.put("fec", fec);
|
||||
json.put("no_nat", noNat);
|
||||
json.put("device_mode", deviceMode);
|
||||
json.put("allow_mapping", allowMapping);
|
||||
|
||||
// 数组
|
||||
if (!portMapping.isEmpty()) {
|
||||
JSONArray mappingArray = new JSONArray();
|
||||
for (String mapping : portMapping) {
|
||||
mappingArray.put(mapping);
|
||||
}
|
||||
json.put("port_mapping", mappingArray);
|
||||
}
|
||||
|
||||
if (!udpStun.isEmpty()) {
|
||||
JSONArray stunArray = new JSONArray();
|
||||
for (String stun : udpStun) {
|
||||
stunArray.put(stun);
|
||||
}
|
||||
json.put("udp_stun", stunArray);
|
||||
}
|
||||
|
||||
if (!tcpStun.isEmpty()) {
|
||||
JSONArray stunArray = new JSONArray();
|
||||
for (String stun : tcpStun) {
|
||||
stunArray.put(stun);
|
||||
}
|
||||
json.put("tcp_stun", stunArray);
|
||||
}
|
||||
|
||||
return json.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置构建器
|
||||
*/
|
||||
public static class Builder {
|
||||
private List<String> servers = new ArrayList<>();
|
||||
private String networkCode;
|
||||
private String password;
|
||||
private String deviceId;
|
||||
private String deviceName;
|
||||
private String tunName;
|
||||
private String outboundInterface;
|
||||
private String ip;
|
||||
private String certMode;
|
||||
private boolean noPunch = false;
|
||||
private boolean compress = false;
|
||||
private boolean rtx = false;
|
||||
private boolean fec = false;
|
||||
private boolean noNat = false;
|
||||
private String deviceMode = "tun";
|
||||
private Integer mtu;
|
||||
private boolean allowMapping = false;
|
||||
private List<String> portMapping = new ArrayList<>();
|
||||
private List<String> udpStun = new ArrayList<>();
|
||||
private List<String> tcpStun = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* 添加服务器地址(必填)
|
||||
* @param server 服务器地址,格式:tcp://host:port 或 wss://host:port
|
||||
*/
|
||||
public Builder addServer(String server) {
|
||||
this.servers.add(server);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置网络代码(必填)
|
||||
* @param networkCode 组网代码
|
||||
*/
|
||||
public Builder setNetworkCode(String networkCode) {
|
||||
this.networkCode = networkCode;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置密码(可选)
|
||||
*/
|
||||
public Builder setPassword(String password) {
|
||||
this.password = password;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置设备ID(可选,默认自动生成)
|
||||
*/
|
||||
public Builder setDeviceId(String deviceId) {
|
||||
this.deviceId = deviceId;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置设备名称(可选)
|
||||
*/
|
||||
public Builder setDeviceName(String deviceName) {
|
||||
this.deviceName = deviceName;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置TUN设备名称(可选)
|
||||
*/
|
||||
public Builder setTunName(String tunName) {
|
||||
this.tunName = tunName;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定对外通信Socket的出口网卡名称(可选)
|
||||
*/
|
||||
public Builder setOutboundInterface(String outboundInterface) {
|
||||
this.outboundInterface = outboundInterface;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置固定IP(可选)
|
||||
*/
|
||||
public Builder setIp(String ip) {
|
||||
this.ip = ip;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置证书验证模式(可选)
|
||||
* @param certMode "insecure" | "system" | "embedded"
|
||||
*/
|
||||
public Builder setCertMode(String certMode) {
|
||||
this.certMode = certMode;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用打洞(默认false)
|
||||
*/
|
||||
public Builder setNoPunch(boolean noPunch) {
|
||||
this.noPunch = noPunch;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用压缩(默认false)
|
||||
*/
|
||||
public Builder setCompress(boolean compress) {
|
||||
this.compress = compress;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用QUIC重传(默认false)
|
||||
*/
|
||||
public Builder setRtx(boolean rtx) {
|
||||
this.rtx = rtx;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用FEC冗余传输(默认false)
|
||||
*/
|
||||
public Builder setFec(boolean fec) {
|
||||
this.fec = fec;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用NAT(默认false)
|
||||
*/
|
||||
public Builder setNoNat(boolean noNat) {
|
||||
this.noNat = noNat;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置虚拟网卡模式:no、tun(默认)或 tap。
|
||||
*/
|
||||
public Builder setDeviceMode(String deviceMode) {
|
||||
if (!"no".equals(deviceMode) && !"tun".equals(deviceMode) && !"tap".equals(deviceMode)) {
|
||||
throw new IllegalArgumentException("deviceMode must be no, tun, or tap");
|
||||
}
|
||||
this.deviceMode = deviceMode;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置MTU(可选,默认1380)
|
||||
*/
|
||||
public Builder setMtu(int mtu) {
|
||||
this.mtu = mtu;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 允许端口映射(默认false)
|
||||
*/
|
||||
public Builder setAllowMapping(boolean allowMapping) {
|
||||
this.allowMapping = allowMapping;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加端口映射规则(可选)
|
||||
* @param mapping 格式:tcp:80->192.168.1.100:8080
|
||||
*/
|
||||
public Builder addPortMapping(String mapping) {
|
||||
this.portMapping.add(mapping);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加UDP STUN服务器(可选)
|
||||
*/
|
||||
public Builder addUdpStun(String stun) {
|
||||
this.udpStun.add(stun);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加TCP STUN服务器(可选)
|
||||
*/
|
||||
public Builder addTcpStun(String stun) {
|
||||
this.tcpStun.add(stun);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建配置对象
|
||||
*/
|
||||
public VntConfig build() {
|
||||
if (servers.isEmpty()) {
|
||||
throw new IllegalArgumentException("At least one server must be specified");
|
||||
}
|
||||
if (networkCode == null || networkCode.isEmpty()) {
|
||||
throw new IllegalArgumentException("Network code must be specified");
|
||||
}
|
||||
return new VntConfig(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.vnt;
|
||||
|
||||
/**
|
||||
* VNT异常
|
||||
*
|
||||
* VNT操作失败时抛出的异常
|
||||
*/
|
||||
public class VntException extends Exception {
|
||||
|
||||
public VntException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public VntException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
public VntException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.vnt;
|
||||
|
||||
/**
|
||||
* VNT网络管理器 - 主入口类
|
||||
*
|
||||
* 使用示例:
|
||||
* 1. 初始化: VntManager.init()
|
||||
* 2. 创建网络: VntNetwork network = VntManager.createNetwork(config)
|
||||
* 3. 注册: RegisterResult result = network.register()
|
||||
* 4. (Android端用result的IP/掩码创建VPN接口,获取tunFd)
|
||||
* 5. 启动TUN: network.startTun(tunFd)
|
||||
* 6. 获取API: VntApi api = network.getApi()
|
||||
* 7. 关闭: network.stop()
|
||||
*/
|
||||
public class VntManager {
|
||||
|
||||
static {
|
||||
// 加载JNI库
|
||||
System.loadLibrary("vnt_jni");
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化VNT模块(全局初始化,只需调用一次)
|
||||
* @return true表示成功,false表示失败
|
||||
*/
|
||||
public static boolean init() {
|
||||
return nativeInit();
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁VNT模块(全局清理)
|
||||
*/
|
||||
public static void destroy() {
|
||||
nativeDestroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建网络实例
|
||||
* @param config 网络配置对象
|
||||
* @return VntNetwork实例,失败返回null
|
||||
*/
|
||||
public static VntNetwork createNetwork(VntConfig config) {
|
||||
String configJson = config.toJson();
|
||||
long handle = nativeCreateNetwork(configJson);
|
||||
if (handle < 0) {
|
||||
return null;
|
||||
}
|
||||
return new VntNetwork(handle);
|
||||
}
|
||||
|
||||
// ========== Native 方法 ==========
|
||||
|
||||
private static native boolean nativeInit();
|
||||
private static native void nativeDestroy();
|
||||
private static native long nativeCreateNetwork(String configJson);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.vnt;
|
||||
|
||||
/**
|
||||
* VNT网络实例
|
||||
*
|
||||
* 代表一个VNT网络连接,持有native资源
|
||||
*/
|
||||
public class VntNetwork {
|
||||
|
||||
private long nativeHandle;
|
||||
private boolean closed = false;
|
||||
|
||||
// 包内构造,只能通过VntManager创建
|
||||
VntNetwork(long handle) {
|
||||
this.nativeHandle = handle;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册网络(连接服务器)
|
||||
* @return 注册结果,包含分配的IP、掩码等信息
|
||||
* @throws VntException 注册失败时抛出异常
|
||||
*/
|
||||
public RegisterResult register() throws VntException {
|
||||
checkClosed();
|
||||
String resultJson = nativeRegister(nativeHandle);
|
||||
return RegisterResult.fromJson(resultJson);
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动TUN设备
|
||||
* @param tunFd TUN设备文件描述符(Android VpnService.Builder.establish()返回的fd)
|
||||
* 传入-1表示让VNT自动创建(仅非Android平台支持)
|
||||
* @throws VntException 启动失败时抛出异常
|
||||
*/
|
||||
public void startTun(int tunFd) throws VntException {
|
||||
checkClosed();
|
||||
if (!nativeStartTun(nativeHandle, tunFd)) {
|
||||
throw new VntException("Failed to start TUN device");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置网络IP(仅非Android平台使用)
|
||||
* @param ip IP地址
|
||||
* @param prefixLen 前缀长度
|
||||
* @throws VntException 设置失败时抛出异常
|
||||
*/
|
||||
public void setNetworkIp(String ip, int prefixLen) throws VntException {
|
||||
checkClosed();
|
||||
if (!nativeSetNetworkIp(nativeHandle, ip, prefixLen)) {
|
||||
throw new VntException("Failed to set network IP");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取VNT API实例
|
||||
* @return VntApi实例
|
||||
* @throws VntException 获取失败时抛出异常
|
||||
*/
|
||||
public VntApi getApi() throws VntException {
|
||||
checkClosed();
|
||||
long apiHandle = nativeGetApi(nativeHandle);
|
||||
if (apiHandle < 0) {
|
||||
throw new VntException("Failed to get VntApi");
|
||||
}
|
||||
return new VntApi(apiHandle);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为无TUN模式
|
||||
* @return true表示无TUN模式
|
||||
*/
|
||||
public boolean isNoTun() {
|
||||
checkClosed();
|
||||
return nativeIsNoTun(nativeHandle);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止并关闭网络
|
||||
*/
|
||||
public void stop() {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
nativeStop(nativeHandle);
|
||||
closed = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取native句柄(供内部使用)
|
||||
*/
|
||||
long getNativeHandle() {
|
||||
return nativeHandle;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已关闭
|
||||
*/
|
||||
private void checkClosed() {
|
||||
if (closed) {
|
||||
throw new IllegalStateException("VntNetwork has been closed");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void finalize() throws Throwable {
|
||||
try {
|
||||
stop();
|
||||
} finally {
|
||||
super.finalize();
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Native 方法 ==========
|
||||
|
||||
private static native String nativeRegister(long handle);
|
||||
private static native boolean nativeStartTun(long handle, int tunFd);
|
||||
private static native boolean nativeSetNetworkIp(long handle, String ip, int prefixLen);
|
||||
private static native long nativeGetApi(long handle);
|
||||
private static native boolean nativeIsNoTun(long handle);
|
||||
private static native boolean nativeStop(long handle);
|
||||
}
|
||||
@@ -0,0 +1,1123 @@
|
||||
use anyhow::Context;
|
||||
use jni::JNIEnv;
|
||||
use jni::objects::{JClass, JObject, JString};
|
||||
use jni::sys::{jboolean, jint, jlong, jstring};
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::sync::Arc;
|
||||
use tokio::runtime::Runtime;
|
||||
use vnt_core::api::VntApi;
|
||||
use vnt_core::context::config::{Config, DeviceMode};
|
||||
use vnt_core::core::{NetworkManager, RegisterResponse};
|
||||
use vnt_core::nat::NetInput;
|
||||
use vnt_core::port_mapping::PortMapping;
|
||||
use vnt_core::tls::verifier::CertValidationMode;
|
||||
use vnt_core::tunnel_core::server::transport::config::ProtocolAddress;
|
||||
use vnt_core::utils::task_control::{TaskGroupGuard, TaskGroupManager};
|
||||
|
||||
/// 全局状态管理
|
||||
struct GlobalState {
|
||||
/// Tokio运行时(Arc包装以便多线程访问)
|
||||
runtime: Arc<Runtime>,
|
||||
/// 网络管理器实例
|
||||
network_managers: HashMap<i64, Arc<Mutex<Option<NetworkManager>>>>,
|
||||
/// API实例
|
||||
vnt_apis: HashMap<i64, VntApi>,
|
||||
/// 任务组管理器
|
||||
task_group_managers: HashMap<i64, TaskGroupManager>,
|
||||
/// 任务组守卫(drop 时会停止任务组,必须持有到 nativeStop)
|
||||
task_group_guards: HashMap<i64, TaskGroupGuard>,
|
||||
/// 下一个实例ID
|
||||
next_id: i64,
|
||||
}
|
||||
|
||||
impl GlobalState {
|
||||
fn new() -> anyhow::Result<Self> {
|
||||
Ok(Self {
|
||||
runtime: Arc::new(Runtime::new()?),
|
||||
network_managers: HashMap::new(),
|
||||
vnt_apis: HashMap::new(),
|
||||
task_group_managers: HashMap::new(),
|
||||
task_group_guards: HashMap::new(),
|
||||
next_id: 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref GLOBAL_STATE: Mutex<Option<GlobalState>> = Mutex::new(None);
|
||||
}
|
||||
|
||||
/// 从 panic payload 中提取错误消息
|
||||
fn panic_message(e: Box<dyn std::any::Any + Send>) -> String {
|
||||
if let Some(s) = e.downcast_ref::<&str>() {
|
||||
s.to_string()
|
||||
} else if let Some(s) = e.downcast_ref::<String>() {
|
||||
s.clone()
|
||||
} else {
|
||||
"unknown panic".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 捕获闭包内的 panic,转为 Err(消息),防止 panic 跨 FFI unwind 导致宿主 abort
|
||||
fn catch_jni_panic<F, T>(f: F) -> Result<T, String>
|
||||
where
|
||||
F: FnOnce() -> T,
|
||||
{
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).map_err(panic_message)
|
||||
}
|
||||
|
||||
fn encryption_state(local_key: Option<&str>, peer_key: Option<&str>) -> i32 {
|
||||
match (local_key, peer_key) {
|
||||
(Some(local), Some(peer)) if local == peer => 1,
|
||||
(None, None) => 2,
|
||||
(Some(_), None) => 3,
|
||||
(None, Some(_)) => 4,
|
||||
(Some(_), Some(_)) => 5,
|
||||
}
|
||||
}
|
||||
|
||||
/// JNI 导出函数的 panic 防护:panic 时向 JVM 抛出异常并返回默认值
|
||||
macro_rules! jni_guard {
|
||||
($env:ident, $default_ret:expr, { $($body:tt)* }) => {{
|
||||
match catch_jni_panic(|| {
|
||||
$($body)*
|
||||
}) {
|
||||
Ok(v) => v,
|
||||
Err(msg) => {
|
||||
let _ = $env.throw(format!("VNT native panic: {}", msg));
|
||||
$default_ret
|
||||
}
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
/// 初始化JNI模块
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_vnt_VntManager_nativeInit(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
) -> jboolean {
|
||||
jni_guard!(env, 0, {
|
||||
let mut state = GLOBAL_STATE.lock();
|
||||
if state.is_some() {
|
||||
return 1; // 已经初始化
|
||||
}
|
||||
|
||||
match GlobalState::new() {
|
||||
Ok(global_state) => {
|
||||
*state = Some(global_state);
|
||||
1
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = env.throw(format!("Failed to initialize VNT: {:?}", e));
|
||||
0
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 销毁JNI模块
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_vnt_VntManager_nativeDestroy(_env: JNIEnv, _class: JClass) {
|
||||
let _ = catch_jni_panic(|| {
|
||||
let mut state = GLOBAL_STATE.lock();
|
||||
*state = None;
|
||||
});
|
||||
}
|
||||
|
||||
/// 创建网络实例
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_vnt_VntManager_nativeCreateNetwork<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
_class: JClass<'local>,
|
||||
config_json: JString<'local>,
|
||||
) -> jlong {
|
||||
jni_guard!(env, -1, {
|
||||
let result: anyhow::Result<i64> = (|| {
|
||||
let mut global_state = GLOBAL_STATE.lock();
|
||||
let state = global_state.as_mut().context("VNT not initialized")?;
|
||||
|
||||
// 解析配置JSON
|
||||
let config_str: String = env.get_string(&config_json)?.into();
|
||||
let config = parse_config_from_json(&config_str)?;
|
||||
|
||||
// 创建任务组
|
||||
let task_group_manager = TaskGroupManager::new();
|
||||
let (task_group, task_group_guard) = task_group_manager
|
||||
.create_task()
|
||||
.context("create task group")?;
|
||||
|
||||
// 获取runtime的clone
|
||||
let runtime = state.runtime.clone();
|
||||
|
||||
// 创建网络管理器
|
||||
let network_manager = runtime.block_on(async {
|
||||
NetworkManager::create_network(Box::new(config), task_group).await
|
||||
})?;
|
||||
|
||||
// 分配ID
|
||||
let id = state.next_id;
|
||||
state.next_id += 1;
|
||||
|
||||
// 保存实例(task_group_guard 必须随实例一直持有,drop 会停止整个任务组)
|
||||
state
|
||||
.network_managers
|
||||
.insert(id, Arc::new(Mutex::new(Some(network_manager))));
|
||||
state.task_group_managers.insert(id, task_group_manager);
|
||||
state.task_group_guards.insert(id, task_group_guard);
|
||||
|
||||
Ok(id)
|
||||
})();
|
||||
|
||||
match result {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
let _ = env.throw(format!("Failed to create network: {:?}", e));
|
||||
-1
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 注册网络
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_vnt_VntNetwork_nativeRegister<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
_class: JClass<'local>,
|
||||
handle: jlong,
|
||||
) -> jstring {
|
||||
jni_guard!(env, std::ptr::null_mut(), {
|
||||
let result: anyhow::Result<String> = (|| {
|
||||
let (network_manager_arc, runtime) = {
|
||||
let mut global_state = GLOBAL_STATE.lock();
|
||||
let state = global_state.as_mut().context("VNT not initialized")?;
|
||||
|
||||
let network_manager_arc = state
|
||||
.network_managers
|
||||
.get(&handle)
|
||||
.context("Invalid handle")?
|
||||
.clone();
|
||||
|
||||
let runtime = state.runtime.clone();
|
||||
(network_manager_arc, runtime)
|
||||
};
|
||||
|
||||
let response = {
|
||||
let mut manager_lock = network_manager_arc.lock();
|
||||
let manager = manager_lock
|
||||
.as_mut()
|
||||
.context("Network manager already destroyed")?;
|
||||
|
||||
runtime.block_on(async { manager.register().await })?
|
||||
};
|
||||
|
||||
match response {
|
||||
RegisterResponse::Success(network_addr) => {
|
||||
let response_json = serde_json::json!({
|
||||
"success": true,
|
||||
"ip": network_addr.ip.to_string(),
|
||||
"prefix_len": network_addr.prefix_len,
|
||||
"gateway": network_addr.gateway.to_string(),
|
||||
"broadcast": network_addr.broadcast.to_string(),
|
||||
});
|
||||
Ok(response_json.to_string())
|
||||
}
|
||||
RegisterResponse::Failed(error_msg) => {
|
||||
let response_json = serde_json::json!({
|
||||
"success": false,
|
||||
"error": error_msg.message,
|
||||
});
|
||||
Ok(response_json.to_string())
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
match result {
|
||||
Ok(json_str) => env
|
||||
.new_string(json_str)
|
||||
.unwrap_or_else(|_| JObject::null().into())
|
||||
.into_raw(),
|
||||
Err(e) => {
|
||||
let _ = env.throw(format!("Failed to register: {:?}", e));
|
||||
JObject::null().into_raw()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 启动TUN设备(Android使用,需要传入fd)
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_vnt_VntNetwork_nativeStartTun(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
tun_fd: jint,
|
||||
) -> jboolean {
|
||||
jni_guard!(env, 0, {
|
||||
let result: anyhow::Result<()> = (|| {
|
||||
let (network_manager_arc, runtime) = {
|
||||
let mut global_state = GLOBAL_STATE.lock();
|
||||
let state = global_state.as_mut().context("VNT not initialized")?;
|
||||
|
||||
let network_manager_arc = state
|
||||
.network_managers
|
||||
.get(&handle)
|
||||
.context("Invalid handle")?
|
||||
.clone();
|
||||
|
||||
let runtime = state.runtime.clone();
|
||||
(network_manager_arc, runtime)
|
||||
};
|
||||
|
||||
let mut manager_lock = network_manager_arc.lock();
|
||||
let manager = manager_lock
|
||||
.as_mut()
|
||||
.context("Network manager already destroyed")?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let tun_fd = if tun_fd < 0 { None } else { Some(tun_fd) };
|
||||
runtime.block_on(async { manager.start_device_fd(tun_fd).await })?;
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = tun_fd; // 避免未使用警告
|
||||
runtime.block_on(async { manager.start_device().await })?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
match result {
|
||||
Ok(_) => 1,
|
||||
Err(e) => {
|
||||
let _ = env.throw(format!("Failed to start TUN: {:?}", e));
|
||||
0
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 设置网络IP(非Android系统)
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_vnt_VntNetwork_nativeSetNetworkIp<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
_class: JClass<'local>,
|
||||
handle: jlong,
|
||||
ip: JString<'local>,
|
||||
prefix_len: jint,
|
||||
) -> jboolean {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
let _ = (handle, ip, prefix_len);
|
||||
let _ = env.throw("set_network_ip is not supported on Android");
|
||||
0
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
jni_guard!(env, 0, {
|
||||
let result: anyhow::Result<()> = (|| {
|
||||
let (network_manager_arc, runtime) = {
|
||||
let mut global_state = GLOBAL_STATE.lock();
|
||||
let state = global_state.as_mut().context("VNT not initialized")?;
|
||||
|
||||
let network_manager_arc = state
|
||||
.network_managers
|
||||
.get(&handle)
|
||||
.context("Invalid handle")?
|
||||
.clone();
|
||||
|
||||
let runtime = state.runtime.clone();
|
||||
(network_manager_arc, runtime)
|
||||
};
|
||||
|
||||
let ip_str: String = env.get_string(&ip)?.into();
|
||||
let ip_addr: Ipv4Addr = ip_str.parse().context("Invalid IP address")?;
|
||||
let manager_lock = network_manager_arc.lock();
|
||||
let manager = manager_lock
|
||||
.as_ref()
|
||||
.context("Network manager already destroyed")?;
|
||||
runtime.block_on(async {
|
||||
manager
|
||||
.set_device_network_ip(ip_addr, prefix_len as u8)
|
||||
.await
|
||||
})?;
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
match result {
|
||||
Ok(_) => 1,
|
||||
Err(e) => {
|
||||
let _ = env.throw(format!("Failed to set network IP: {:?}", e));
|
||||
0
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取VntApi实例
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_vnt_VntNetwork_nativeGetApi(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
) -> jlong {
|
||||
jni_guard!(env, -1, {
|
||||
let result: anyhow::Result<i64> = (|| {
|
||||
let mut global_state = GLOBAL_STATE.lock();
|
||||
let state = global_state.as_mut().context("VNT not initialized")?;
|
||||
|
||||
let network_manager_arc = state
|
||||
.network_managers
|
||||
.get(&handle)
|
||||
.context("Invalid handle")?
|
||||
.clone();
|
||||
|
||||
let api = {
|
||||
let manager_lock = network_manager_arc.lock();
|
||||
let manager = manager_lock
|
||||
.as_ref()
|
||||
.context("Network manager already destroyed")?;
|
||||
manager.vnt_api()
|
||||
};
|
||||
|
||||
state.vnt_apis.insert(handle, api);
|
||||
Ok(handle)
|
||||
})();
|
||||
|
||||
match result {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
let _ = env.throw(format!("Failed to get API: {:?}", e));
|
||||
-1
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 检查是否为无TUN模式
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_vnt_VntNetwork_nativeIsNoTun(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
) -> jboolean {
|
||||
jni_guard!(env, 0, {
|
||||
let result: anyhow::Result<bool> = (|| {
|
||||
let global_state = GLOBAL_STATE.lock();
|
||||
let state = global_state.as_ref().context("VNT not initialized")?;
|
||||
|
||||
let network_manager_arc = state
|
||||
.network_managers
|
||||
.get(&handle)
|
||||
.context("Invalid handle")?
|
||||
.clone();
|
||||
|
||||
let manager_lock = network_manager_arc.lock();
|
||||
let manager = manager_lock
|
||||
.as_ref()
|
||||
.context("Network manager already destroyed")?;
|
||||
|
||||
Ok(manager.device_mode() == DeviceMode::No)
|
||||
})();
|
||||
|
||||
match result {
|
||||
Ok(is_no_device) => {
|
||||
if is_no_device {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = env.throw(format!("Failed to check device mode: {:?}", e));
|
||||
0
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 关闭网络
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_vnt_VntNetwork_nativeStop(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
handle: jlong,
|
||||
) -> jboolean {
|
||||
jni_guard!(env, 0, {
|
||||
let result: anyhow::Result<()> = (|| {
|
||||
let mut global_state = GLOBAL_STATE.lock();
|
||||
let state = global_state.as_mut().context("VNT not initialized")?;
|
||||
|
||||
// 停止任务组
|
||||
if let Some(task_group_manager) = state.task_group_managers.get(&handle) {
|
||||
task_group_manager.stop();
|
||||
}
|
||||
|
||||
// 移除网络管理器
|
||||
state.network_managers.remove(&handle);
|
||||
state.vnt_apis.remove(&handle);
|
||||
state.task_group_managers.remove(&handle);
|
||||
// 最后释放守卫(drop 时会停止任务组)
|
||||
state.task_group_guards.remove(&handle);
|
||||
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
match result {
|
||||
Ok(_) => 1,
|
||||
Err(e) => {
|
||||
let _ = env.throw(format!("Failed to stop network: {:?}", e));
|
||||
0
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== VntApi 接口 ====================
|
||||
|
||||
/// 获取客户端列表
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_vnt_VntApi_nativeGetClientList<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
_class: JClass<'local>,
|
||||
api_handle: jlong,
|
||||
) -> jstring {
|
||||
jni_guard!(env, std::ptr::null_mut(), {
|
||||
let result: anyhow::Result<String> = (|| {
|
||||
let (api, runtime) = {
|
||||
let global_state = GLOBAL_STATE.lock();
|
||||
let state = global_state.as_ref().context("VNT not initialized")?;
|
||||
(
|
||||
state
|
||||
.vnt_apis
|
||||
.get(&api_handle)
|
||||
.context("Invalid API handle")?
|
||||
.clone(),
|
||||
state.runtime.clone(),
|
||||
)
|
||||
};
|
||||
|
||||
let local_clients: HashMap<_, _> = api
|
||||
.client_ips()
|
||||
.into_iter()
|
||||
.map(|client| (client.ip, client.online))
|
||||
.collect();
|
||||
let server_clients: HashMap<_, _> = runtime
|
||||
.block_on(api.server_rpc().client_list())
|
||||
.map(|response| {
|
||||
response
|
||||
.list
|
||||
.into_iter()
|
||||
.map(|client| (Ipv4Addr::from(client.ip), client))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let local_key = api.get_config().and_then(|config| config.key_sign());
|
||||
let mut ips: Vec<_> = local_clients
|
||||
.keys()
|
||||
.chain(server_clients.keys())
|
||||
.copied()
|
||||
.collect();
|
||||
ips.sort_unstable();
|
||||
ips.dedup();
|
||||
|
||||
let json_array: Vec<_> = ips
|
||||
.into_iter()
|
||||
.map(|ip| {
|
||||
let server_client = server_clients.get(&ip);
|
||||
let route = api.find_route(&ip);
|
||||
let has_route = route.is_some();
|
||||
let direct = route
|
||||
.as_ref()
|
||||
.map(|route| route.metric() == 1)
|
||||
.unwrap_or(false);
|
||||
let route_protocol = route
|
||||
.as_ref()
|
||||
.map(|route| route.route_key().protocol().to_string());
|
||||
let route_metric = route.as_ref().map(|route| route.metric());
|
||||
let rtt = route.as_ref().map(|route| route.rtt());
|
||||
let online = local_clients.get(&ip).copied().unwrap_or(false)
|
||||
|| server_client.map(|client| client.online).unwrap_or(false)
|
||||
|| has_route;
|
||||
let packet_loss = api.packet_loss_info(&ip).map(|info| {
|
||||
serde_json::json!({
|
||||
"sent": info.sent,
|
||||
"received": info.received,
|
||||
"loss_rate": info.loss_rate,
|
||||
})
|
||||
});
|
||||
let traffic = api.traffic_info(&ip).map(|info| {
|
||||
serde_json::json!({
|
||||
"tx_bytes": info.tx_bytes,
|
||||
"rx_bytes": info.rx_bytes,
|
||||
})
|
||||
});
|
||||
serde_json::json!({
|
||||
"ip": ip.to_string(),
|
||||
"name": server_client.map(|client| client.name.as_str()).unwrap_or(""),
|
||||
"version": server_client.map(|client| client.version.as_str()).unwrap_or(""),
|
||||
"online": online,
|
||||
"direct": direct,
|
||||
"route_protocol": route_protocol,
|
||||
"route_metric": route_metric,
|
||||
"rtt": rtt,
|
||||
"key_equal": server_client
|
||||
.map(|client| encryption_state(local_key.as_deref(), client.key_sign.as_deref()))
|
||||
.unwrap_or(0),
|
||||
"packet_loss": packet_loss,
|
||||
"traffic": traffic,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(serde_json::to_string(&json_array)?)
|
||||
})();
|
||||
|
||||
match result {
|
||||
Ok(json_str) => env
|
||||
.new_string(json_str)
|
||||
.unwrap_or_else(|_| JObject::null().into())
|
||||
.into_raw(),
|
||||
Err(e) => {
|
||||
let _ = env.throw(format!("Failed to get client list: {:?}", e));
|
||||
JObject::null().into_raw()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取网络配置信息
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_vnt_VntApi_nativeGetNetwork<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
_class: JClass<'local>,
|
||||
api_handle: jlong,
|
||||
) -> jstring {
|
||||
jni_guard!(env, std::ptr::null_mut(), {
|
||||
let result: anyhow::Result<String> = (|| {
|
||||
let global_state = GLOBAL_STATE.lock();
|
||||
let state = global_state.as_ref().context("VNT not initialized")?;
|
||||
|
||||
let api = state
|
||||
.vnt_apis
|
||||
.get(&api_handle)
|
||||
.context("Invalid API handle")?;
|
||||
|
||||
if let Some(network) = api.network() {
|
||||
let json = serde_json::json!({
|
||||
"ip": network.ip.to_string(),
|
||||
"prefix_len": network.prefix_len,
|
||||
"gateway": network.gateway.to_string(),
|
||||
"broadcast": network.broadcast.to_string(),
|
||||
});
|
||||
Ok(json.to_string())
|
||||
} else {
|
||||
Ok("null".to_string())
|
||||
}
|
||||
})();
|
||||
|
||||
match result {
|
||||
Ok(json_str) => env
|
||||
.new_string(json_str)
|
||||
.unwrap_or_else(|_| JObject::null().into())
|
||||
.into_raw(),
|
||||
Err(e) => {
|
||||
let _ = env.throw(format!("Failed to get network info: {:?}", e));
|
||||
JObject::null().into_raw()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取NAT信息
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_vnt_VntApi_nativeGetNatInfo<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
_class: JClass<'local>,
|
||||
api_handle: jlong,
|
||||
) -> jstring {
|
||||
jni_guard!(env, std::ptr::null_mut(), {
|
||||
let result: anyhow::Result<String> = (|| {
|
||||
let global_state = GLOBAL_STATE.lock();
|
||||
let state = global_state.as_ref().context("VNT not initialized")?;
|
||||
|
||||
let api = state
|
||||
.vnt_apis
|
||||
.get(&api_handle)
|
||||
.context("Invalid API handle")?;
|
||||
|
||||
if let Some(nat_info) = api.nat_info() {
|
||||
let json = serde_json::json!({
|
||||
"nat_type": format!("{:?}", nat_info.nat_type),
|
||||
"public_ips": nat_info.public_ips.iter().map(|ip| ip.to_string()).collect::<Vec<_>>(),
|
||||
"ipv6": nat_info.ipv6.map(|ip| ip.to_string()),
|
||||
});
|
||||
Ok(json.to_string())
|
||||
} else {
|
||||
Ok("null".to_string())
|
||||
}
|
||||
})();
|
||||
|
||||
match result {
|
||||
Ok(json_str) => env
|
||||
.new_string(json_str)
|
||||
.unwrap_or_else(|_| JObject::null().into())
|
||||
.into_raw(),
|
||||
Err(e) => {
|
||||
let _ = env.throw(format!("Failed to get NAT info: {:?}", e));
|
||||
JObject::null().into_raw()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取服务器节点列表
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_vnt_VntApi_nativeGetServerList<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
_class: JClass<'local>,
|
||||
api_handle: jlong,
|
||||
) -> jstring {
|
||||
jni_guard!(env, std::ptr::null_mut(), {
|
||||
let result: anyhow::Result<String> = (|| {
|
||||
let global_state = GLOBAL_STATE.lock();
|
||||
let state = global_state.as_ref().context("VNT not initialized")?;
|
||||
|
||||
let api = state
|
||||
.vnt_apis
|
||||
.get(&api_handle)
|
||||
.context("Invalid API handle")?;
|
||||
|
||||
let servers = api.server_node_list();
|
||||
let json_array: Vec<_> = servers
|
||||
.into_iter()
|
||||
.map(|server| {
|
||||
serde_json::json!({
|
||||
"server_id": server.server_id,
|
||||
"server_addr": server.server_addr.to_string(),
|
||||
"connected": server.connected,
|
||||
"rtt": server.rtt,
|
||||
"data_version": server.data_version,
|
||||
"server_version": server.server_version,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(serde_json::to_string(&json_array)?)
|
||||
})();
|
||||
|
||||
match result {
|
||||
Ok(json_str) => env
|
||||
.new_string(json_str)
|
||||
.unwrap_or_else(|_| JObject::null().into())
|
||||
.into_raw(),
|
||||
Err(e) => {
|
||||
let _ = env.throw(format!("Failed to get server list: {:?}", e));
|
||||
JObject::null().into_raw()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取路由表
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_vnt_VntApi_nativeGetRouteTable<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
_class: JClass<'local>,
|
||||
api_handle: jlong,
|
||||
) -> jstring {
|
||||
jni_guard!(env, std::ptr::null_mut(), {
|
||||
let result: anyhow::Result<String> = (|| {
|
||||
let global_state = GLOBAL_STATE.lock();
|
||||
let state = global_state.as_ref().context("VNT not initialized")?;
|
||||
|
||||
let api = state
|
||||
.vnt_apis
|
||||
.get(&api_handle)
|
||||
.context("Invalid API handle")?;
|
||||
|
||||
let route_table = api.route_table();
|
||||
let json_data: Vec<_> = route_table
|
||||
.into_iter()
|
||||
.map(|(ip, routes)| {
|
||||
let routes_json: Vec<_> = routes
|
||||
.into_iter()
|
||||
.map(|route| {
|
||||
serde_json::json!({
|
||||
"route_key": route.route_key().to_string(),
|
||||
"protocol": route.route_key().protocol().to_string(),
|
||||
"metric": route.metric(),
|
||||
"rtt": route.rtt(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
serde_json::json!({
|
||||
"ip": ip.to_string(),
|
||||
"routes": routes_json,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::to_string(&json_data)?)
|
||||
})();
|
||||
|
||||
match result {
|
||||
Ok(json_str) => env
|
||||
.new_string(json_str)
|
||||
.unwrap_or_else(|_| JObject::null().into())
|
||||
.into_raw(),
|
||||
Err(e) => {
|
||||
let _ = env.throw(format!("Failed to get route table: {:?}", e));
|
||||
JObject::null().into_raw()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 检查目标IP是否直连
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_vnt_VntApi_nativeIsDirect<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
_class: JClass<'local>,
|
||||
api_handle: jlong,
|
||||
ip: JString<'local>,
|
||||
) -> jboolean {
|
||||
jni_guard!(env, 0, {
|
||||
let result: anyhow::Result<bool> = (|| {
|
||||
let global_state = GLOBAL_STATE.lock();
|
||||
let state = global_state.as_ref().context("VNT not initialized")?;
|
||||
|
||||
let api = state
|
||||
.vnt_apis
|
||||
.get(&api_handle)
|
||||
.context("Invalid API handle")?;
|
||||
|
||||
let ip_str: String = env.get_string(&ip)?.into();
|
||||
let ip_addr: Ipv4Addr = ip_str.parse().context("Invalid IP address")?;
|
||||
|
||||
Ok(api.is_direct(&ip_addr))
|
||||
})();
|
||||
|
||||
match result {
|
||||
Ok(is_direct) => {
|
||||
if is_direct {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = env.throw(format!("Failed to check direct: {:?}", e));
|
||||
0
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取对端NAT信息
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_vnt_VntApi_nativeGetPeerNatInfo<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
_class: JClass<'local>,
|
||||
api_handle: jlong,
|
||||
ip: JString<'local>,
|
||||
) -> jstring {
|
||||
jni_guard!(env, std::ptr::null_mut(), {
|
||||
let result: anyhow::Result<String> = (|| {
|
||||
let global_state = GLOBAL_STATE.lock();
|
||||
let state = global_state.as_ref().context("VNT not initialized")?;
|
||||
|
||||
let api = state
|
||||
.vnt_apis
|
||||
.get(&api_handle)
|
||||
.context("Invalid API handle")?;
|
||||
|
||||
let ip_str: String = env.get_string(&ip)?.into();
|
||||
let ip_addr: Ipv4Addr = ip_str.parse().context("Invalid IP address")?;
|
||||
|
||||
if let Some(nat_info) = api.peer_nat_info(&ip_addr) {
|
||||
let json = serde_json::json!({
|
||||
"nat_type": format!("{:?}", nat_info.nat_type),
|
||||
"public_ips": nat_info.public_ips.iter().map(|ip| ip.to_string()).collect::<Vec<_>>(),
|
||||
"ipv6": nat_info.ipv6.map(|ip| ip.to_string()),
|
||||
});
|
||||
Ok(json.to_string())
|
||||
} else {
|
||||
Ok("null".to_string())
|
||||
}
|
||||
})();
|
||||
|
||||
match result {
|
||||
Ok(json_str) => env
|
||||
.new_string(json_str)
|
||||
.unwrap_or_else(|_| JObject::null().into())
|
||||
.into_raw(),
|
||||
Err(e) => {
|
||||
let _ = env.throw(format!("Failed to get peer NAT info: {:?}", e));
|
||||
JObject::null().into_raw()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取丢包信息
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_vnt_VntApi_nativeGetPacketLoss<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
_class: JClass<'local>,
|
||||
api_handle: jlong,
|
||||
ip: JString<'local>,
|
||||
) -> jstring {
|
||||
jni_guard!(env, std::ptr::null_mut(), {
|
||||
let result: anyhow::Result<String> = (|| {
|
||||
let global_state = GLOBAL_STATE.lock();
|
||||
let state = global_state.as_ref().context("VNT not initialized")?;
|
||||
|
||||
let api = state
|
||||
.vnt_apis
|
||||
.get(&api_handle)
|
||||
.context("Invalid API handle")?;
|
||||
|
||||
let ip_str: String = env.get_string(&ip)?.into();
|
||||
let ip_addr: Ipv4Addr = ip_str.parse().context("Invalid IP address")?;
|
||||
|
||||
if let Some(loss_info) = api.packet_loss_info(&ip_addr) {
|
||||
let json = serde_json::json!({
|
||||
"ip": loss_info.ip.to_string(),
|
||||
"sent": loss_info.sent,
|
||||
"received": loss_info.received,
|
||||
"loss_rate": loss_info.loss_rate,
|
||||
});
|
||||
Ok(json.to_string())
|
||||
} else {
|
||||
Ok("null".to_string())
|
||||
}
|
||||
})();
|
||||
|
||||
match result {
|
||||
Ok(json_str) => env
|
||||
.new_string(json_str)
|
||||
.unwrap_or_else(|_| JObject::null().into())
|
||||
.into_raw(),
|
||||
Err(e) => {
|
||||
let _ = env.throw(format!("Failed to get packet loss: {:?}", e));
|
||||
JObject::null().into_raw()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取流量信息
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_com_vnt_VntApi_nativeGetTrafficInfo<'local>(
|
||||
mut env: JNIEnv<'local>,
|
||||
_class: JClass<'local>,
|
||||
api_handle: jlong,
|
||||
ip: JString<'local>,
|
||||
) -> jstring {
|
||||
jni_guard!(env, std::ptr::null_mut(), {
|
||||
let result: anyhow::Result<String> = (|| {
|
||||
let global_state = GLOBAL_STATE.lock();
|
||||
let state = global_state.as_ref().context("VNT not initialized")?;
|
||||
|
||||
let api = state
|
||||
.vnt_apis
|
||||
.get(&api_handle)
|
||||
.context("Invalid API handle")?;
|
||||
|
||||
let ip_str: String = env.get_string(&ip)?.into();
|
||||
let ip_addr: Ipv4Addr = ip_str.parse().context("Invalid IP address")?;
|
||||
|
||||
if let Some(traffic_info) = api.traffic_info(&ip_addr) {
|
||||
let json = serde_json::json!({
|
||||
"ip": traffic_info.ip.to_string(),
|
||||
"tx_bytes": traffic_info.tx_bytes,
|
||||
"rx_bytes": traffic_info.rx_bytes,
|
||||
});
|
||||
Ok(json.to_string())
|
||||
} else {
|
||||
Ok("null".to_string())
|
||||
}
|
||||
})();
|
||||
|
||||
match result {
|
||||
Ok(json_str) => env
|
||||
.new_string(json_str)
|
||||
.unwrap_or_else(|_| JObject::null().into())
|
||||
.into_raw(),
|
||||
Err(e) => {
|
||||
let _ = env.throw(format!("Failed to get traffic info: {:?}", e));
|
||||
JObject::null().into_raw()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 辅助函数 ====================
|
||||
|
||||
/// 从JSON字符串解析配置
|
||||
fn parse_config_from_json(json_str: &str) -> anyhow::Result<Config> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ConfigJson {
|
||||
server: Vec<String>,
|
||||
network_code: String,
|
||||
#[serde(default)]
|
||||
device_id: Option<String>,
|
||||
#[serde(default)]
|
||||
device_name: Option<String>,
|
||||
#[serde(default)]
|
||||
tun_name: Option<String>,
|
||||
#[serde(default)]
|
||||
outbound_interface: Option<String>,
|
||||
#[serde(default)]
|
||||
ip: Option<Ipv4Addr>,
|
||||
#[serde(default)]
|
||||
password: Option<String>,
|
||||
#[serde(default)]
|
||||
cert_mode: Option<String>,
|
||||
#[serde(default)]
|
||||
no_punch: bool,
|
||||
#[serde(default)]
|
||||
compress: bool,
|
||||
#[serde(default)]
|
||||
rtx: bool,
|
||||
#[serde(default)]
|
||||
fec: bool,
|
||||
#[serde(default)]
|
||||
input: Vec<NetInput>,
|
||||
#[serde(default)]
|
||||
output: Vec<ipnet::Ipv4Net>,
|
||||
#[serde(default)]
|
||||
no_nat: bool,
|
||||
#[serde(default)]
|
||||
device_mode: DeviceMode,
|
||||
#[serde(default)]
|
||||
mtu: Option<u16>,
|
||||
#[serde(default)]
|
||||
port_mapping: Vec<String>,
|
||||
#[serde(default)]
|
||||
allow_mapping: bool,
|
||||
#[serde(default)]
|
||||
udp_stun: Vec<String>,
|
||||
#[serde(default)]
|
||||
tcp_stun: Vec<String>,
|
||||
#[serde(default)]
|
||||
tunnel_port: Option<u16>,
|
||||
}
|
||||
|
||||
let cfg: ConfigJson = serde_json::from_str(json_str)?;
|
||||
|
||||
let server_addrs: Vec<ProtocolAddress> = cfg
|
||||
.server
|
||||
.iter()
|
||||
.map(|s| {
|
||||
s.parse()
|
||||
.map_err(|e| anyhow::anyhow!("invalid server address '{}': {}", s, e))
|
||||
})
|
||||
.collect::<anyhow::Result<_>>()?;
|
||||
|
||||
let port_mapping: Vec<PortMapping> = cfg
|
||||
.port_mapping
|
||||
.iter()
|
||||
.map(|s| {
|
||||
s.parse()
|
||||
.map_err(|e| anyhow::anyhow!("invalid port_mapping '{}': {}", s, e))
|
||||
})
|
||||
.collect::<anyhow::Result<_>>()?;
|
||||
|
||||
let cert_mode = match cfg.cert_mode.as_deref() {
|
||||
Some(s) => s
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("invalid cert_mode '{}': {}", s, e))?,
|
||||
None => CertValidationMode::InsecureSkipVerification,
|
||||
};
|
||||
|
||||
let device_id = match cfg.device_id {
|
||||
Some(id) => id,
|
||||
None => vnt_core::utils::device_id::get_device_id()
|
||||
.map_err(|e| anyhow::anyhow!("failed to get device_id: {}", e))?,
|
||||
};
|
||||
|
||||
let device_name = cfg.device_name.unwrap_or_else(|| {
|
||||
hostname::get()
|
||||
.ok()
|
||||
.and_then(|v| v.into_string().ok())
|
||||
.unwrap_or_default()
|
||||
});
|
||||
|
||||
let mut udp_stun = cfg.udp_stun;
|
||||
for x in udp_stun.iter_mut() {
|
||||
if !x.contains(':') {
|
||||
x.push_str(":3478");
|
||||
}
|
||||
}
|
||||
|
||||
let mut tcp_stun = cfg.tcp_stun;
|
||||
for x in tcp_stun.iter_mut() {
|
||||
if !x.contains(':') {
|
||||
x.push_str(":3478");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Config {
|
||||
server_addr: server_addrs,
|
||||
network_code: cfg.network_code,
|
||||
ip: cfg.ip,
|
||||
no_punch: cfg.no_punch,
|
||||
rtx: cfg.rtx,
|
||||
compress: cfg.compress,
|
||||
device_id,
|
||||
device_name,
|
||||
tun_name: cfg.tun_name,
|
||||
outbound_interface: cfg.outbound_interface,
|
||||
password: cfg.password,
|
||||
cert_mode,
|
||||
input: cfg.input,
|
||||
output: cfg.output,
|
||||
no_nat: cfg.no_nat,
|
||||
device_mode: cfg.device_mode,
|
||||
mtu: cfg.mtu,
|
||||
port_mapping,
|
||||
allow_port_mapping: cfg.allow_mapping,
|
||||
udp_stun,
|
||||
tcp_stun,
|
||||
fec: cfg.fec,
|
||||
tunnel_port: cfg.tunnel_port,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn catch_jni_panic_returns_value_unchanged() {
|
||||
let result = catch_jni_panic(|| 42);
|
||||
assert_eq!(result, Ok(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catch_jni_panic_captures_str_message() {
|
||||
let result: Result<(), String> = catch_jni_panic(|| panic!("boom"));
|
||||
let err = result.unwrap_err();
|
||||
assert!(err.contains("boom"), "unexpected message: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catch_jni_panic_captures_string_message() {
|
||||
let result: Result<(), String> = catch_jni_panic(|| panic!("{}", "kaboom"));
|
||||
assert_eq!(result.unwrap_err(), "kaboom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_peer_encryption_states() {
|
||||
assert_eq!(encryption_state(Some("same"), Some("same")), 1);
|
||||
assert_eq!(encryption_state(None, None), 2);
|
||||
assert_eq!(encryption_state(Some("local"), None), 3);
|
||||
assert_eq!(encryption_state(None, Some("peer")), 4);
|
||||
assert_eq!(encryption_state(Some("local"), Some("peer")), 5);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,26 @@
|
||||
[package]
|
||||
name = "vnt-web"
|
||||
version = "2.0.0"
|
||||
version = "2.0.2"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
vnt-core = { path = "../vnt-core" }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
vnt-core.workspace = true
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
axum = "0.8.8"
|
||||
tower-http = { version = "0.6", features = ["fs", "cors", "trace"] }
|
||||
tower-http = { version = "0.7", features = ["cors"] }
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
anyhow = "1.0.100"
|
||||
log = "0.4.29"
|
||||
ipnet = "2.11.0"
|
||||
hostname = "0.4.2"
|
||||
route_manager = "0.2.11"
|
||||
toml = "0.9.8"
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
ipnet.workspace = true
|
||||
hostname.workspace = true
|
||||
route_manager.workspace = true
|
||||
toml.workspace = true
|
||||
rust-embed = "8.0"
|
||||
mime_guess = "2.0"
|
||||
parking_lot = "0.12"
|
||||
time = { version = "0.3.45", features = ["local-offset", "formatting", "macros"] }
|
||||
parking_lot.workspace = true
|
||||
time = { workspace = true, features = ["local-offset", "formatting", "macros"] }
|
||||
tokio-util.workspace = true
|
||||
rand = "0.10"
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
//! 构建 vnt-web 前自动构建前端 UI(vnt-web/ui -> vnt-web/static)。
|
||||
//!
|
||||
//! - 前端源码(ui/src 等)比 static 产物新、或产物缺失时,调用 pnpm 构建
|
||||
//! - 产物已是最新则跳过,不拖慢增量编译
|
||||
//! - 找不到 pnpm 时:已有产物则告警并沿用;没有产物则报错并给出指引
|
||||
//! - 设置环境变量 VNT_WEB_SKIP_UI_BUILD=1 可完全跳过前端构建
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::time::SystemTime;
|
||||
|
||||
fn main() {
|
||||
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR");
|
||||
let manifest_dir = Path::new(&manifest_dir);
|
||||
let ui_dir = manifest_dir.join("ui");
|
||||
let static_dir = manifest_dir.join("static");
|
||||
let workspace_root = manifest_dir.parent().expect("workspace root");
|
||||
|
||||
// UI 源码变化时重新运行本脚本
|
||||
println!("cargo:rerun-if-changed={}", ui_dir.join("src").display());
|
||||
println!(
|
||||
"cargo:rerun-if-changed={}",
|
||||
ui_dir.join("index.html").display()
|
||||
);
|
||||
println!(
|
||||
"cargo:rerun-if-changed={}",
|
||||
ui_dir.join("vite.config.js").display()
|
||||
);
|
||||
println!(
|
||||
"cargo:rerun-if-changed={}",
|
||||
ui_dir.join("package.json").display()
|
||||
);
|
||||
// 产物被删除时也要重新运行
|
||||
println!(
|
||||
"cargo:rerun-if-changed={}",
|
||||
static_dir.join("index.html").display()
|
||||
);
|
||||
println!("cargo:rerun-if-env-changed=VNT_WEB_SKIP_UI_BUILD");
|
||||
|
||||
if std::env::var("VNT_WEB_SKIP_UI_BUILD").is_ok() {
|
||||
ensure_static_placeholder(&static_dir);
|
||||
return;
|
||||
}
|
||||
|
||||
if static_is_fresh(&ui_dir, &static_dir) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(pnpm) = find_pnpm() else {
|
||||
if static_dir.join("index.html").is_file() {
|
||||
println!(
|
||||
"cargo:warning=未找到 pnpm,沿用 vnt-web/static 中已有的前端产物(可能不是最新)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
panic!(
|
||||
"未找到 pnpm 且 vnt-web/static 没有前端产物。\n\
|
||||
请安装 Node.js 与 pnpm 后重新构建(cargo 会自动完成前端构建),\n\
|
||||
或从发布包中获取 static 目录放入 vnt-web/。"
|
||||
);
|
||||
};
|
||||
|
||||
if !ui_dir.join("node_modules").is_dir() {
|
||||
// ui 依赖使用 workspace catalog,必须在仓库根目录安装
|
||||
run_or_panic(pnpm, &["install", "--frozen-lockfile"], workspace_root);
|
||||
}
|
||||
run_or_panic(pnpm, &["--filter", "vnt-web-ui", "build"], workspace_root);
|
||||
}
|
||||
|
||||
/// pnpm 命令名(Windows 上是 pnpm.cmd,由 cmd.exe 执行)
|
||||
fn find_pnpm() -> Option<&'static str> {
|
||||
let candidates: &[&str] = if cfg!(windows) {
|
||||
&["pnpm.cmd", "pnpm"]
|
||||
} else {
|
||||
&["pnpm"]
|
||||
};
|
||||
candidates
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|cmd| Command::new(cmd).arg("--version").output().is_ok())
|
||||
}
|
||||
|
||||
fn run_or_panic(program: &str, args: &[&str], dir: &Path) {
|
||||
println!(
|
||||
"cargo:warning=执行前端构建: {} {} ({})",
|
||||
program,
|
||||
args.join(" "),
|
||||
dir.display()
|
||||
);
|
||||
let status = Command::new(program)
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.status()
|
||||
.unwrap_or_else(|e| panic!("执行 {} 失败: {}", program, e));
|
||||
if !status.success() {
|
||||
panic!(
|
||||
"前端构建失败: {} {} (exit: {:?})",
|
||||
program,
|
||||
args.join(" "),
|
||||
status.code()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// static 产物是否比 UI 源码新
|
||||
fn static_is_fresh(ui_dir: &Path, static_dir: &Path) -> bool {
|
||||
let Ok(built_at) = std::fs::metadata(static_dir.join("index.html")).and_then(|m| m.modified())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
newest_mtime(&ui_dir.join("src")).is_none_or(|t| t <= built_at)
|
||||
}
|
||||
|
||||
fn newest_mtime(dir: &Path) -> Option<SystemTime> {
|
||||
let mut newest: Option<SystemTime> = None;
|
||||
let mut stack = vec![dir.to_path_buf()];
|
||||
while let Some(d) = stack.pop() {
|
||||
for entry in std::fs::read_dir(&d).ok()?.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
} else if let Ok(m) = entry.metadata().and_then(|m| m.modified()) {
|
||||
newest = Some(newest.map_or(m, |n| n.max(m)));
|
||||
}
|
||||
}
|
||||
}
|
||||
newest
|
||||
}
|
||||
|
||||
/// 跳过构建时保证 static/ 存在,使 rust_embed 可以编译
|
||||
fn ensure_static_placeholder(static_dir: &Path) {
|
||||
if static_dir.join("index.html").is_file() {
|
||||
return;
|
||||
}
|
||||
println!("cargo:warning=VNT_WEB_SKIP_UI_BUILD 已设置且 static 为空,写入占位页面");
|
||||
std::fs::create_dir_all(static_dir).expect("创建 static 目录失败");
|
||||
std::fs::write(
|
||||
static_dir.join("index.html"),
|
||||
"<!doctype html><html><body><p>VNT Web UI 未构建。请安装 pnpm 后重新执行 cargo build,\
|
||||
或取消 VNT_WEB_SKIP_UI_BUILD。</p></body></html>",
|
||||
)
|
||||
.expect("写入占位页面失败");
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
mod service_http;
|
||||
|
||||
pub use service_http::run_http_server;
|
||||
pub use service_http::{VntService, generate_access_token, run_http_server};
|
||||
|
||||
struct ScopeGuard<F: FnOnce()>(Option<F>);
|
||||
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
use crate::defer;
|
||||
use anyhow::{Context, anyhow};
|
||||
use axum::body::Body;
|
||||
use axum::http::{HeaderMap, HeaderValue, StatusCode, Uri, header};
|
||||
use anyhow::{Context, anyhow, bail};
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, Uri, header};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Query, Request, State},
|
||||
middleware,
|
||||
response::Response,
|
||||
routing::{get, post},
|
||||
routing::{delete, get, post},
|
||||
};
|
||||
use ipnet::Ipv4Net;
|
||||
use mime_guess::from_path;
|
||||
use parking_lot::Mutex;
|
||||
use rand::RngExt;
|
||||
use rust_embed::RustEmbed;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
@@ -23,10 +24,12 @@ use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
use time::{OffsetDateTime, format_description};
|
||||
use tokio::fs;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tower::ServiceExt;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use vnt_core::api::VntApi;
|
||||
use vnt_core::context::config::Config as CoreConfig;
|
||||
use vnt_core::core::{DEFAULT_MTU, NetworkManager};
|
||||
use vnt_core::context::config::{Config as CoreConfig, DeviceMode};
|
||||
use vnt_core::core::{DEFAULT_MTU, NetworkManager, RegisterResponse};
|
||||
use vnt_core::nat::NetInput;
|
||||
use vnt_core::port_mapping::PortMapping;
|
||||
use vnt_core::tls::verifier::CertValidationMode;
|
||||
@@ -36,7 +39,7 @@ use vnt_core::utils::task_control::TaskGroupManager;
|
||||
const CONFIG_DIR: &str = "vnt_config";
|
||||
const CURRENT_CONFIG_RECORD: &str = "vnt_current_config.txt";
|
||||
|
||||
#[derive(Serialize, Clone, Copy, PartialEq, Eq, Default)]
|
||||
#[derive(Serialize, Clone, Copy, PartialEq, Eq, Default, Debug)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum VntStatus {
|
||||
#[default]
|
||||
@@ -47,77 +50,157 @@ enum VntStatus {
|
||||
|
||||
#[derive(Clone)]
|
||||
struct HttpAppState {
|
||||
task_group_manager: TaskGroupManager,
|
||||
inner: Arc<Mutex<HttpAppStateInner>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct HttpAppStateInner {
|
||||
/// 组网实例表,key = 配置文件名,同一配置最多一个实例
|
||||
instances: HashMap<String, InstanceState>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct InstanceState {
|
||||
vnt: Option<VntHandler>,
|
||||
status: VntStatus,
|
||||
start_logs: Vec<String>,
|
||||
/// 启动任务句柄,用于在 Starting 状态中断注册重试循环
|
||||
start_handle: Option<tokio::task::JoinHandle<()>>,
|
||||
/// 每个实例持有自己的任务组管理器(TaskGroupManager 是单槽的,不能共享)
|
||||
task_group_manager: TaskGroupManager,
|
||||
/// 启动时解析出的配置快照,用于多实例启动前冲突检测
|
||||
start_config: Option<StartConfig>,
|
||||
/// 展示名;Starting 阶段还没有 vnt,用配置里的 config_name 或 file_name 兜底
|
||||
config_name: String,
|
||||
}
|
||||
|
||||
impl HttpAppState {
|
||||
fn starting(&self) -> anyhow::Result<()> {
|
||||
fn starting(&self, file_name: &str) -> anyhow::Result<()> {
|
||||
let mut inner = self.inner.lock();
|
||||
if inner.status != VntStatus::Stopped {
|
||||
return Err(anyhow!("VNT is already starting or running"));
|
||||
let inst = inner.instances.entry(file_name.to_string()).or_default();
|
||||
if inst.status != VntStatus::Stopped {
|
||||
return Err(anyhow!("配置 {} 正在启动或已运行", file_name));
|
||||
}
|
||||
if inner.vnt.is_some() {
|
||||
return Err(anyhow!("VNT is already running"));
|
||||
if inst.vnt.is_some() {
|
||||
return Err(anyhow!("配置 {} 已在运行", file_name));
|
||||
}
|
||||
inner.status = VntStatus::Starting;
|
||||
inner.start_logs.clear();
|
||||
inst.status = VntStatus::Starting;
|
||||
inst.start_logs.clear();
|
||||
inst.start_config = None;
|
||||
inst.config_name = file_name.to_string();
|
||||
Ok(())
|
||||
}
|
||||
fn stopped(&self) {
|
||||
fn stopped(&self, file_name: &str) {
|
||||
let mut inner = self.inner.lock();
|
||||
inner.vnt.take();
|
||||
inner.status = VntStatus::Stopped;
|
||||
let Some(inst) = inner.instances.get_mut(file_name) else {
|
||||
return;
|
||||
};
|
||||
inst.vnt.take();
|
||||
inst.status = VntStatus::Stopped;
|
||||
inst.start_config = None;
|
||||
// 已完成任务的句柄只是残留,不算运行内容
|
||||
if inst.start_handle.as_ref().is_some_and(|h| h.is_finished()) {
|
||||
inst.start_handle.take();
|
||||
}
|
||||
fn starting_to_stopped(&self) {
|
||||
// 实例已无任何运行内容时移除条目,避免实例表堆积已停止的配置。
|
||||
// 注意 Starting 失败路径走 record_log_and_stopped/starting_to_stopped 保留日志,
|
||||
// 不经过这里,不会被误删。
|
||||
let removable = inst.start_handle.is_none() && inst.task_group_manager.is_stopped();
|
||||
if removable {
|
||||
inner.instances.remove(file_name);
|
||||
}
|
||||
}
|
||||
fn starting_to_stopped(&self, file_name: &str) {
|
||||
let mut inner = self.inner.lock();
|
||||
if inner.status != VntStatus::Starting {
|
||||
let Some(inst) = inner.instances.get_mut(file_name) else {
|
||||
return;
|
||||
};
|
||||
if inst.status != VntStatus::Starting {
|
||||
return;
|
||||
}
|
||||
inner.vnt.take();
|
||||
inner.status = VntStatus::Stopped;
|
||||
inner
|
||||
.start_logs
|
||||
inst.vnt.take();
|
||||
inst.status = VntStatus::Stopped;
|
||||
inst.start_logs
|
||||
.push(format!("[{}] 启动中断", HttpAppState::timestamp()));
|
||||
}
|
||||
fn starting_to_running(&self) {
|
||||
fn starting_to_running(&self, file_name: &str) {
|
||||
let mut inner = self.inner.lock();
|
||||
if inner.status != VntStatus::Starting {
|
||||
let Some(inst) = inner.instances.get_mut(file_name) else {
|
||||
return;
|
||||
};
|
||||
if inst.status != VntStatus::Starting {
|
||||
log::error!("starting_to_running VNT is not starting");
|
||||
return;
|
||||
}
|
||||
inner.status = VntStatus::Running;
|
||||
inner.start_logs.clear();
|
||||
inst.status = VntStatus::Running;
|
||||
inst.start_logs.clear();
|
||||
}
|
||||
|
||||
fn record_log(&self, msg: impl Into<String>) {
|
||||
fn record_log(&self, file_name: &str, msg: impl Into<String>) {
|
||||
let mut inner = self.inner.lock();
|
||||
if inner.status != VntStatus::Starting {
|
||||
let Some(inst) = inner.instances.get_mut(file_name) else {
|
||||
return;
|
||||
};
|
||||
if inst.status != VntStatus::Starting {
|
||||
return;
|
||||
}
|
||||
inner
|
||||
.start_logs
|
||||
inst.start_logs
|
||||
.push(format!("[{}] {}", Self::timestamp(), msg.into()));
|
||||
}
|
||||
fn record_log_and_stopped(&self, msg: impl Into<String>) {
|
||||
fn record_log_and_stopped(&self, file_name: &str, msg: impl Into<String>) {
|
||||
let mut inner = self.inner.lock();
|
||||
if inner.status != VntStatus::Starting {
|
||||
let Some(inst) = inner.instances.get_mut(file_name) else {
|
||||
return;
|
||||
};
|
||||
if inst.status != VntStatus::Starting {
|
||||
return;
|
||||
}
|
||||
inner
|
||||
.start_logs
|
||||
inst.start_logs
|
||||
.push(format!("[{}] {}", Self::timestamp(), msg.into()));
|
||||
inner.status = VntStatus::Stopped;
|
||||
inst.status = VntStatus::Stopped;
|
||||
}
|
||||
fn status(&self, file_name: &str) -> VntStatus {
|
||||
self.inner
|
||||
.lock()
|
||||
.instances
|
||||
.get(file_name)
|
||||
.map(|inst| inst.status)
|
||||
.unwrap_or(VntStatus::Stopped)
|
||||
}
|
||||
|
||||
fn task_group_manager(&self, file_name: &str) -> Option<TaskGroupManager> {
|
||||
self.inner
|
||||
.lock()
|
||||
.instances
|
||||
.get(file_name)
|
||||
.map(|inst| inst.task_group_manager.clone())
|
||||
}
|
||||
|
||||
/// 启动解析出配置后写入展示名和配置快照(供实例列表与冲突检测使用)
|
||||
fn set_starting_config(&self, file_name: &str, config_name: String, cfg: StartConfig) {
|
||||
if let Some(inst) = self.inner.lock().instances.get_mut(file_name) {
|
||||
inst.config_name = config_name;
|
||||
inst.start_config = Some(cfg);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_start_handle(&self, file_name: &str, handle: tokio::task::JoinHandle<()>) {
|
||||
if let Some(inst) = self.inner.lock().instances.get_mut(file_name) {
|
||||
inst.start_handle = Some(handle);
|
||||
}
|
||||
}
|
||||
|
||||
/// 中断启动任务(如注册重试循环)。任务已完成时为空操作。
|
||||
fn abort_start_task(&self, file_name: &str) {
|
||||
let handle = self
|
||||
.inner
|
||||
.lock()
|
||||
.instances
|
||||
.get_mut(file_name)
|
||||
.and_then(|inst| inst.start_handle.take());
|
||||
if let Some(handle) = handle {
|
||||
handle.abort();
|
||||
}
|
||||
fn status(&self) -> VntStatus {
|
||||
self.inner.lock().status
|
||||
}
|
||||
|
||||
fn timestamp() -> String {
|
||||
@@ -132,6 +215,8 @@ struct VntHandler {
|
||||
api: VntApi,
|
||||
config_name: String,
|
||||
config_file_name: String,
|
||||
/// 启动时的配置快照,用于多实例冲突检测
|
||||
start_config: StartConfig,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -159,7 +244,7 @@ impl<T> ApiResponse<T> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct StartConfig {
|
||||
pub config_name: Option<String>,
|
||||
pub server: Vec<String>,
|
||||
@@ -168,6 +253,7 @@ pub struct StartConfig {
|
||||
pub device_id: Option<String>,
|
||||
pub device_name: Option<String>,
|
||||
pub tun_name: Option<String>,
|
||||
pub outbound_interface: Option<String>,
|
||||
pub ip: Option<Ipv4Addr>,
|
||||
pub password: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -185,7 +271,9 @@ pub struct StartConfig {
|
||||
#[serde(default)]
|
||||
pub no_nat: bool,
|
||||
#[serde(default)]
|
||||
pub no_tun: bool,
|
||||
pub device_mode: DeviceMode,
|
||||
#[serde(default, rename = "no_tun", skip_serializing)]
|
||||
pub legacy_no_tun: Option<bool>,
|
||||
pub mtu: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub port_mapping: Vec<String>,
|
||||
@@ -195,6 +283,16 @@ pub struct StartConfig {
|
||||
pub udp_stun: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub tcp_stun: Vec<String>,
|
||||
pub tunnel_port: Option<u16>,
|
||||
}
|
||||
|
||||
impl StartConfig {
|
||||
fn reject_legacy_no_tun(&self) -> anyhow::Result<()> {
|
||||
if self.legacy_no_tun.is_some() {
|
||||
bail!("configuration key 'no_tun' was removed; use device_mode = \"no|tun|tap\"")
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -238,6 +336,8 @@ struct HttpAppInfo {
|
||||
compress: Option<bool>,
|
||||
encrypt: Option<bool>,
|
||||
rtx: Option<bool>,
|
||||
/// 启动后配置文件是否发生过变化(与启动时的配置快照对比)
|
||||
config_changed: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -294,6 +394,7 @@ struct HttpRouteDetail {
|
||||
protocol: String,
|
||||
metric: u8,
|
||||
rtt: u32,
|
||||
loss_rate: u16,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -302,14 +403,61 @@ struct StartStatusResponse {
|
||||
logs: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct InstanceSummary {
|
||||
file_name: String,
|
||||
config_name: String,
|
||||
status: VntStatus,
|
||||
}
|
||||
|
||||
async fn get_start_status(
|
||||
State(state): State<HttpAppState>,
|
||||
Query(req): Query<FileReq>,
|
||||
) -> Json<ApiResponse<StartStatusResponse>> {
|
||||
let lock = state.inner.lock();
|
||||
Json(ApiResponse::success(StartStatusResponse {
|
||||
status: lock.status,
|
||||
logs: lock.start_logs.clone(),
|
||||
}))
|
||||
// 实例不存在(从未启动或已停止并清理)时返回 Stopped + 空日志,
|
||||
// 前端轮询已停止实例时自然终止
|
||||
let resp = match lock.instances.get(&req.file_name) {
|
||||
Some(inst) => StartStatusResponse {
|
||||
status: inst.status,
|
||||
logs: inst.start_logs.clone(),
|
||||
},
|
||||
None => StartStatusResponse {
|
||||
status: VntStatus::Stopped,
|
||||
logs: Vec::new(),
|
||||
},
|
||||
};
|
||||
Json(ApiResponse::success(resp))
|
||||
}
|
||||
|
||||
async fn get_instances(
|
||||
State(state): State<HttpAppState>,
|
||||
) -> Json<ApiResponse<Vec<InstanceSummary>>> {
|
||||
let lock = state.inner.lock();
|
||||
let mut list: Vec<InstanceSummary> = lock
|
||||
.instances
|
||||
.iter()
|
||||
.map(|(file_name, inst)| {
|
||||
let config_name = inst
|
||||
.vnt
|
||||
.as_ref()
|
||||
.map(|v| v.config_name.clone())
|
||||
.unwrap_or_else(|| {
|
||||
if inst.config_name.is_empty() {
|
||||
file_name.clone()
|
||||
} else {
|
||||
inst.config_name.clone()
|
||||
}
|
||||
});
|
||||
InstanceSummary {
|
||||
file_name: file_name.clone(),
|
||||
config_name,
|
||||
status: inst.status,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
list.sort_by(|a, b| a.file_name.cmp(&b.file_name));
|
||||
Json(ApiResponse::success(list))
|
||||
}
|
||||
|
||||
async fn logging_middleware(req: Request, next: axum::middleware::Next) -> Response {
|
||||
@@ -331,23 +479,49 @@ async fn logging_middleware(req: Request, next: axum::middleware::Next) -> Respo
|
||||
#[folder = "static/"]
|
||||
struct Asset;
|
||||
|
||||
pub async fn run_http_server(
|
||||
addr: SocketAddr,
|
||||
/// 进程内 VNT 业务服务。HTTP 和 Tauri IPC 共用同一组 handler 与状态。
|
||||
#[derive(Clone)]
|
||||
pub struct VntService {
|
||||
router: Router,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ServiceRuntime {
|
||||
StandaloneWeb,
|
||||
DesktopWeb,
|
||||
}
|
||||
|
||||
impl ServiceRuntime {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::StandaloneWeb => "standalone_web",
|
||||
Self::DesktopWeb => "desktop_web",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl VntService {
|
||||
pub async fn new(start_config_file_name: Option<PathBuf>) -> anyhow::Result<Self> {
|
||||
Self::new_with_runtime(start_config_file_name, ServiceRuntime::StandaloneWeb).await
|
||||
}
|
||||
|
||||
pub async fn new_desktop(start_config_file_name: Option<PathBuf>) -> anyhow::Result<Self> {
|
||||
Self::new_with_runtime(start_config_file_name, ServiceRuntime::DesktopWeb).await
|
||||
}
|
||||
|
||||
async fn new_with_runtime(
|
||||
start_config_file_name: Option<PathBuf>,
|
||||
) -> anyhow::Result<()> {
|
||||
runtime: ServiceRuntime,
|
||||
) -> anyhow::Result<Self> {
|
||||
fs::create_dir_all(CONFIG_DIR)
|
||||
.await
|
||||
.context("Failed to create config directory")?;
|
||||
|
||||
let state = HttpAppState {
|
||||
task_group_manager: TaskGroupManager::new(),
|
||||
inner: Arc::new(Default::default()),
|
||||
};
|
||||
|
||||
// 自动启动逻辑
|
||||
let auto_start_file = determine_auto_start_file(start_config_file_name).await;
|
||||
|
||||
if let Some((file_name, path)) = auto_start_file {
|
||||
for (file_name, path) in determine_auto_start_files(start_config_file_name).await {
|
||||
log::info!("Auto starting VNT with config: {:?}", path);
|
||||
let state_clone = state.clone();
|
||||
tokio::spawn(async move {
|
||||
@@ -357,16 +531,70 @@ pub async fn run_http_server(
|
||||
});
|
||||
}
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
Ok(Self {
|
||||
router: api_router(state, runtime),
|
||||
})
|
||||
}
|
||||
|
||||
let app = Router::new()
|
||||
/// 由 Tauri command 调用,不经过 TCP/HTTP 监听端口。
|
||||
pub async fn request(
|
||||
&self,
|
||||
method: &str,
|
||||
path: &str,
|
||||
body: Option<String>,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let method = Method::from_bytes(method.as_bytes()).context("Invalid request method")?;
|
||||
let request = axum::http::Request::builder()
|
||||
.method(method)
|
||||
.uri(path)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(body.unwrap_or_default()))?;
|
||||
let response = self.router.clone().oneshot(request).await?;
|
||||
let status = response.status();
|
||||
let bytes = to_bytes(response.into_body(), 8 * 1024 * 1024).await?;
|
||||
let value: serde_json::Value = serde_json::from_slice(&bytes)
|
||||
.with_context(|| format!("Invalid service response ({status})"))?;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// 在当前进程中按需开放带令牌鉴权的 Web 服务。
|
||||
pub async fn start_http(
|
||||
&self,
|
||||
addr: SocketAddr,
|
||||
token: String,
|
||||
cancellation: CancellationToken,
|
||||
) -> anyhow::Result<tokio::task::JoinHandle<anyhow::Result<()>>> {
|
||||
let listener = TcpListener::bind(addr).await?;
|
||||
let actual_addr = listener.local_addr()?;
|
||||
let app = http_router(self.router.clone(), token);
|
||||
log::info!("HTTP API Listening on http://{}", actual_addr);
|
||||
Ok(tokio::spawn(async move {
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(cancellation.cancelled_owned())
|
||||
.await?;
|
||||
Ok(())
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_access_token() -> String {
|
||||
let mut bytes = [0_u8; 24];
|
||||
rand::rng().fill(&mut bytes);
|
||||
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
fn api_router(state: HttpAppState, runtime: ServiceRuntime) -> Router {
|
||||
let get_runtime =
|
||||
move || async move { Json(ApiResponse::success(runtime.as_str().to_string())) };
|
||||
Router::new()
|
||||
.route("/api/version", get(get_version))
|
||||
.route("/api/runtime", get(get_runtime))
|
||||
.route("/api/info", get(get_info))
|
||||
.route("/api/peers", get(get_peers))
|
||||
.route("/api/routes", get(get_routes))
|
||||
.route("/api/start/status", get(get_start_status))
|
||||
.route("/api/instances", get(get_instances))
|
||||
.route("/api/instance", delete(dismiss_instance_handler))
|
||||
.route("/api/start", post(start_vnt_handler))
|
||||
.route("/api/stop", post(stop_vnt_handler))
|
||||
.route("/api/restart", post(restart_vnt_handler))
|
||||
@@ -375,45 +603,139 @@ pub async fn run_http_server(
|
||||
"/api/config",
|
||||
get(get_config).post(save_config).delete(delete_config),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn token_auth_middleware(
|
||||
State(token): State<String>,
|
||||
req: Request,
|
||||
next: axum::middleware::Next,
|
||||
) -> Response {
|
||||
let authorized = req
|
||||
.headers()
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.strip_prefix("Bearer "))
|
||||
.is_some_and(|provided| provided == token);
|
||||
if !authorized {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(ApiResponse::<()>::error("访问令牌无效或已过期")),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
next.run(req).await
|
||||
}
|
||||
|
||||
fn http_router(api: Router, token: String) -> Router {
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
Router::new()
|
||||
.merge(api.layer(middleware::from_fn_with_state(token, token_auth_middleware)))
|
||||
.fallback(static_handler)
|
||||
.layer(cors)
|
||||
.layer(middleware::from_fn(logging_middleware))
|
||||
.with_state(state)
|
||||
.fallback(static_handler);
|
||||
}
|
||||
|
||||
log::info!("HTTP API Listening on http://{}", addr);
|
||||
let listener = TcpListener::bind(addr).await?;
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
pub async fn run_http_server(
|
||||
addr: SocketAddr,
|
||||
start_config_file_name: Option<PathBuf>,
|
||||
token: String,
|
||||
) -> anyhow::Result<()> {
|
||||
let service = VntService::new(start_config_file_name).await?;
|
||||
let cancellation = CancellationToken::new();
|
||||
let handle = service
|
||||
.start_http(addr, token, cancellation.clone())
|
||||
.await?;
|
||||
|
||||
shutdown_signal().await;
|
||||
cancellation.cancel();
|
||||
handle.await??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 确定自动启动的配置文件
|
||||
async fn determine_auto_start_file(
|
||||
/// 确定自动启动的配置文件列表。
|
||||
/// --conf 显式指定时只返回那一个;否则读自启记录文件(每行一个 file_name),过滤存在的文件。
|
||||
async fn determine_auto_start_files(
|
||||
start_config_file_name: Option<PathBuf>,
|
||||
) -> Option<(String, PathBuf)> {
|
||||
let path = if let Some(name) = start_config_file_name {
|
||||
Some(name)
|
||||
) -> Vec<(String, PathBuf)> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
let paths: Vec<PathBuf> = if let Some(name) = start_config_file_name {
|
||||
vec![name]
|
||||
} else if Path::new(CURRENT_CONFIG_RECORD).exists() {
|
||||
fs::read_to_string(CURRENT_CONFIG_RECORD)
|
||||
.await
|
||||
.ok()
|
||||
.filter(|content| !content.trim().is_empty())
|
||||
.map(|content| Path::new(CONFIG_DIR).join(content.trim()))
|
||||
match fs::read_to_string(CURRENT_CONFIG_RECORD).await {
|
||||
Ok(content) => content
|
||||
.lines()
|
||||
.map(|line| line.trim())
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(|line| Path::new(CONFIG_DIR).join(line))
|
||||
.collect(),
|
||||
Err(e) => {
|
||||
log::warn!("Failed to read auto start record: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
path.and_then(|p| {
|
||||
let file_name = p.file_name()?.to_str()?.to_string();
|
||||
for p in paths {
|
||||
let Some(file_name) = p
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.to_string())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if result.iter().any(|(name, _)| *name == file_name) {
|
||||
continue;
|
||||
}
|
||||
if p.exists() {
|
||||
Some((file_name, p))
|
||||
result.push((file_name, p));
|
||||
} else {
|
||||
log::warn!("Auto start config file not found: {:?}", p);
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// 读取自启记录文件(每行一个 file_name,去空白、去重)
|
||||
async fn read_running_records() -> Vec<String> {
|
||||
let Ok(content) = fs::read_to_string(CURRENT_CONFIG_RECORD).await else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
for line in content.lines() {
|
||||
let name = line.trim();
|
||||
if !name.is_empty() && !names.iter().any(|n| n == name) {
|
||||
names.push(name.to_string());
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
async fn write_running_records(names: &[String]) {
|
||||
if let Err(e) = fs::write(CURRENT_CONFIG_RECORD, names.join("\n")).await {
|
||||
log::warn!("Failed to record running configs: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动成功后把 file_name 加入自启记录
|
||||
async fn record_add_running(file_name: &str) {
|
||||
let mut names = read_running_records().await;
|
||||
if !names.iter().any(|n| n == file_name) {
|
||||
names.push(file_name.to_string());
|
||||
}
|
||||
write_running_records(&names).await;
|
||||
}
|
||||
|
||||
/// 实例停止后把 file_name 从自启记录移除
|
||||
async fn record_remove_running(file_name: &str) {
|
||||
let mut names = read_running_records().await;
|
||||
names.retain(|n| n != file_name);
|
||||
write_running_records(&names).await;
|
||||
}
|
||||
|
||||
fn build_headers_for_path(path: &str) -> HeaderMap {
|
||||
@@ -442,12 +764,28 @@ fn build_headers_for_path(path: &str) -> HeaderMap {
|
||||
);
|
||||
headers
|
||||
}
|
||||
/// 将请求路径安全地映射到 static 目录内。
|
||||
/// 逐组件校验,拒绝 `..`、根路径、盘符等任何可能逃逸出 static 的路径。
|
||||
fn resolve_static_path(path: &str) -> Option<PathBuf> {
|
||||
let mut local_path = PathBuf::from("static");
|
||||
for component in Path::new(path).components() {
|
||||
match component {
|
||||
std::path::Component::Normal(part) => local_path.push(part),
|
||||
std::path::Component::CurDir => {}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
Some(local_path)
|
||||
}
|
||||
|
||||
async fn static_handler(uri: Uri) -> impl IntoResponse {
|
||||
let path = uri.path().trim_start_matches('/');
|
||||
let path = if path.is_empty() { "index.html" } else { path };
|
||||
|
||||
// 先尝试从本地文件读取
|
||||
let local_path = Path::new("static").join(path);
|
||||
let Some(local_path) = resolve_static_path(path) else {
|
||||
return (StatusCode::NOT_FOUND, "404 Not Found").into_response();
|
||||
};
|
||||
if local_path.is_file()
|
||||
&& let Ok(content) = tokio::fs::read(&local_path).await
|
||||
{
|
||||
@@ -466,6 +804,40 @@ async fn static_handler(uri: Uri) -> impl IntoResponse {
|
||||
(StatusCode::NOT_FOUND, "404 Not Found").into_response()
|
||||
}
|
||||
|
||||
/// 启动前冲突检测:新配置与所有 Starting/Running 实例的配置比对。
|
||||
/// 纯函数,便于单元测试。
|
||||
fn check_config_conflict(new: &StartConfig, running: &[&StartConfig]) -> Result<(), String> {
|
||||
for cfg in running {
|
||||
// device_id 的唯一性只在"同一服务器 + 同一组网编号"范围内成立:
|
||||
// 不同服务器或不同 network_code 的实例互不影响
|
||||
let same_network = new.network_code == cfg.network_code;
|
||||
let server_overlap = (new.server.is_empty() && cfg.server.is_empty())
|
||||
|| new.server.iter().any(|s| cfg.server.contains(s));
|
||||
// 两者都为 None 也算冲突:缺省 device_id 使用同一 machine_uid
|
||||
if same_network && server_overlap && new.device_id == cfg.device_id {
|
||||
return Err(match &new.device_id {
|
||||
Some(id) => format!(
|
||||
"启动冲突:device_id \"{}\" 已被同服务器同组网的运行中实例使用",
|
||||
id
|
||||
),
|
||||
None => {
|
||||
"启动冲突:与同服务器同组网的实例都未指定 device_id,缺省会使用相同的本机标识"
|
||||
.to_string()
|
||||
}
|
||||
});
|
||||
}
|
||||
if let (Some(a), Some(b)) = (new.tunnel_port, cfg.tunnel_port)
|
||||
&& a == b
|
||||
{
|
||||
return Err(format!(
|
||||
"启动冲突:tunnel_port {} 已被其他运行中的实例使用",
|
||||
a
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 启动 VNT 服务的入口函数
|
||||
async fn start_vnt_internal(
|
||||
state: &HttpAppState,
|
||||
@@ -473,44 +845,70 @@ async fn start_vnt_internal(
|
||||
file_path: PathBuf,
|
||||
) -> anyhow::Result<()> {
|
||||
log::info!("Starting VNT service: {}", file_name);
|
||||
state.starting()?;
|
||||
state.starting(&file_name)?;
|
||||
|
||||
let state_for_error = state.clone();
|
||||
let file_name_for_error = file_name.clone();
|
||||
let on_error_guard = defer(move || {
|
||||
state_for_error.starting_to_stopped();
|
||||
state_for_error.starting_to_stopped(&file_name_for_error);
|
||||
});
|
||||
|
||||
state.record_log(format!("启动配置: {}", file_name));
|
||||
state.record_log("读取配置文件");
|
||||
state.record_log(&file_name, format!("启动配置: {}", file_name));
|
||||
state.record_log(&file_name, "读取配置文件");
|
||||
|
||||
// 读取并解析配置
|
||||
let content = fs::read_to_string(&file_path)
|
||||
.await
|
||||
.with_context(|| format!("Config file not found: {:?}", file_path))?;
|
||||
|
||||
state.record_log("解析配置文件内容");
|
||||
state.record_log(&file_name, "解析配置文件内容");
|
||||
let cfg: StartConfig = toml::from_str(&content).context("Failed to parse TOML config")?;
|
||||
|
||||
let config_display_name = cfg.config_name.clone().unwrap_or_else(|| file_name.clone());
|
||||
let core_config = convert_config(cfg)?;
|
||||
let sub_input = core_config.input.clone();
|
||||
|
||||
state.record_log("创建异步任务组");
|
||||
let (task_group, task_group_guard) = state
|
||||
.task_group_manager
|
||||
// 启动前冲突检测:与所有 Starting/Running 实例的配置比对
|
||||
{
|
||||
let inner = state.inner.lock();
|
||||
let running: Vec<&StartConfig> = inner
|
||||
.instances
|
||||
.iter()
|
||||
.filter(|(name, inst)| name.as_str() != file_name && inst.status != VntStatus::Stopped)
|
||||
.filter_map(|(_, inst)| {
|
||||
inst.vnt
|
||||
.as_ref()
|
||||
.map(|v| &v.start_config)
|
||||
.or(inst.start_config.as_ref())
|
||||
})
|
||||
.collect();
|
||||
if let Err(msg) = check_config_conflict(&cfg, &running) {
|
||||
bail!(msg);
|
||||
}
|
||||
}
|
||||
|
||||
state.set_starting_config(&file_name, config_display_name.clone(), cfg.clone());
|
||||
|
||||
let start_config = cfg.clone();
|
||||
let core_config = convert_config(cfg)?;
|
||||
|
||||
state.record_log(&file_name, "创建异步任务组");
|
||||
let task_group_manager = state
|
||||
.task_group_manager(&file_name)
|
||||
.context("Instance not found")?;
|
||||
let (task_group, task_group_guard) = task_group_manager
|
||||
.create_task()
|
||||
.context("Create task failed")?;
|
||||
|
||||
state.record_log("创建组网管理器");
|
||||
state.record_log(&file_name, "创建组网管理器");
|
||||
|
||||
let state_clone = state.clone();
|
||||
tokio::spawn(async move {
|
||||
let file_name_clone = file_name.clone();
|
||||
let start_handle = tokio::spawn(async move {
|
||||
let result = start_vnt_network(
|
||||
state_clone.clone(),
|
||||
file_name,
|
||||
file_name_clone.clone(),
|
||||
config_display_name,
|
||||
start_config,
|
||||
core_config,
|
||||
sub_input,
|
||||
task_group,
|
||||
task_group_guard,
|
||||
)
|
||||
@@ -518,10 +916,11 @@ async fn start_vnt_internal(
|
||||
|
||||
if let Err(e) = result {
|
||||
log::error!("Failed to start VNT network: {:?}", e);
|
||||
state_clone.record_log_and_stopped(format!("启动失败: {}", e));
|
||||
state_clone.record_log_and_stopped(&file_name_clone, format!("启动失败: {}", e));
|
||||
}
|
||||
drop(on_error_guard);
|
||||
});
|
||||
state.set_start_handle(&file_name, start_handle);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -531,12 +930,14 @@ async fn start_vnt_network(
|
||||
state: HttpAppState,
|
||||
file_name: String,
|
||||
config_display_name: String,
|
||||
start_config: StartConfig,
|
||||
core_config: CoreConfig,
|
||||
sub_input: Vec<NetInput>,
|
||||
task_group: vnt_core::utils::task_control::TaskGroup,
|
||||
task_group_guard: vnt_core::utils::task_control::TaskGroupGuard,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut network_manager = NetworkManager::create_network(Box::new(core_config), task_group.clone())
|
||||
let sub_input = core_config.input.clone();
|
||||
let mut network_manager =
|
||||
NetworkManager::create_network(Box::new(core_config), task_group.clone())
|
||||
.await
|
||||
.map_err(|e| anyhow!("Create network failed: {:?}", e))?;
|
||||
|
||||
@@ -544,47 +945,71 @@ async fn start_vnt_network(
|
||||
|
||||
{
|
||||
let mut lock = state.inner.lock();
|
||||
if lock.vnt.is_some() {
|
||||
let Some(inst) = lock.instances.get_mut(&file_name) else {
|
||||
return Err(anyhow!("Instance not found: {}", file_name));
|
||||
};
|
||||
if inst.vnt.is_some() {
|
||||
return Err(anyhow!("VNT is already running"));
|
||||
}
|
||||
lock.vnt = Some(VntHandler {
|
||||
inst.vnt = Some(VntHandler {
|
||||
api: vnt_api,
|
||||
config_name: config_display_name,
|
||||
config_file_name: file_name.clone(),
|
||||
start_config,
|
||||
});
|
||||
}
|
||||
|
||||
let state_for_vnt_cleanup = state.clone();
|
||||
let file_name_for_cleanup = file_name.clone();
|
||||
let vnt_cleanup_guard = defer(move || {
|
||||
state_for_vnt_cleanup.stopped();
|
||||
state_for_vnt_cleanup.stopped(&file_name_for_cleanup);
|
||||
});
|
||||
|
||||
state.record_log("连接服务器,执行注册");
|
||||
state.record_log(&file_name, "连接服务器,执行注册");
|
||||
log::info!("Registering with server");
|
||||
|
||||
let reg_msg = network_manager
|
||||
.register()
|
||||
.await
|
||||
.context("Registration failed")?;
|
||||
|
||||
state.record_log(format!("注册成功 {}/{}", reg_msg.ip, reg_msg.prefix_len));
|
||||
let reg_msg = loop {
|
||||
let reg_msg = match network_manager.register().await {
|
||||
Ok(rs) => rs,
|
||||
Err(e) => {
|
||||
log::error!("Register failed: {:?}", e);
|
||||
state.record_log(&file_name, format!("注册失败:{},5秒后重试", e));
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match reg_msg {
|
||||
RegisterResponse::Success(reg_msg) => {
|
||||
break reg_msg;
|
||||
}
|
||||
RegisterResponse::Failed(e) => {
|
||||
log::error!("Register failed: {:?}", e);
|
||||
bail!("注册失败:{}", e.message)
|
||||
}
|
||||
}
|
||||
};
|
||||
state.record_log(
|
||||
&file_name,
|
||||
format!("注册成功 {}/{}", reg_msg.ip, reg_msg.prefix_len),
|
||||
);
|
||||
log::info!("Network Started: {}/{}", reg_msg.ip, reg_msg.prefix_len);
|
||||
if !network_manager.is_no_tun() {
|
||||
state.record_log("正在创建 TUN 虚拟网卡");
|
||||
network_manager.start_tun().await?;
|
||||
if network_manager.device_mode().has_device() {
|
||||
let mode = network_manager.device_mode();
|
||||
state.record_log(&file_name, format!("正在创建 {} 虚拟网卡", mode));
|
||||
network_manager.start_device().await?;
|
||||
|
||||
state.record_log("创建 TUN 虚拟网卡成功,设置 IP");
|
||||
state.record_log(&file_name, format!("创建 {} 虚拟网卡成功,设置 IP", mode));
|
||||
network_manager
|
||||
.set_network_ip(reg_msg.ip, reg_msg.prefix_len)
|
||||
.set_device_network_ip(reg_msg.ip, reg_msg.prefix_len)
|
||||
.await?;
|
||||
state.record_log("设置 IP 成功");
|
||||
state.record_log(&file_name, "设置 IP 成功");
|
||||
|
||||
// 配置子网路由
|
||||
if !sub_input.is_empty()
|
||||
&& let Ok(if_index) = network_manager.tun_if_index().await
|
||||
&& let Ok(if_index) = network_manager.device_if_index().await
|
||||
&& let Ok(mut route_manager) = route_manager::RouteManager::new()
|
||||
{
|
||||
state.record_log("配置子网路由");
|
||||
state.record_log(&file_name, "配置子网路由");
|
||||
for input in &sub_input {
|
||||
let route =
|
||||
route_manager::Route::new(input.net.network().into(), input.net.prefix_len())
|
||||
@@ -598,23 +1023,28 @@ async fn start_vnt_network(
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
state.record_log(&file_name, "device_mode=no,不创建虚拟网卡");
|
||||
}
|
||||
|
||||
state.starting_to_running();
|
||||
state.starting_to_running(&file_name);
|
||||
|
||||
// 启动网络管理任务
|
||||
task_group.spawn(async move {
|
||||
// 启动成功后记录到自启列表
|
||||
record_add_running(&file_name).await;
|
||||
|
||||
// 启动网络管理任务。
|
||||
// 注意必须在任务组外等待:等待目标就是这个 task_group,
|
||||
// 若 spawn 进组内会形成自引用等待,网络自行停止时永不返回
|
||||
let file_name_for_wait = file_name.clone();
|
||||
tokio::spawn(async move {
|
||||
network_manager.wait_all_stopped().await;
|
||||
drop(task_group_guard);
|
||||
drop(network_manager);
|
||||
drop(vnt_cleanup_guard);
|
||||
record_remove_running(&file_name_for_wait).await;
|
||||
log::info!("Network manager stopped.");
|
||||
});
|
||||
|
||||
// 记录当前配置
|
||||
if let Err(e) = fs::write(CURRENT_CONFIG_RECORD, &file_name).await {
|
||||
log::warn!("Failed to record current config: {}", e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -625,6 +1055,16 @@ fn is_valid_file_name(file_name: &str) -> bool {
|
||||
&& !file_name.contains('\\')
|
||||
}
|
||||
|
||||
/// 规范化配置文件名:无扩展名时补 .toml;扩展名不是 .toml 则拒绝。
|
||||
/// list_configs 只列出 *.toml,不强制后缀会保存出列表中不可见的文件
|
||||
fn normalize_config_file_name(file_name: String) -> Result<String, &'static str> {
|
||||
match Path::new(&file_name).extension() {
|
||||
None => Ok(format!("{file_name}.toml")),
|
||||
Some(ext) if ext == "toml" => Ok(file_name),
|
||||
Some(_) => Err("Config file name must end with .toml"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_vnt_handler(
|
||||
State(state): State<HttpAppState>,
|
||||
Json(req): Json<FileReq>,
|
||||
@@ -644,16 +1084,42 @@ async fn start_vnt_handler(
|
||||
}
|
||||
}
|
||||
|
||||
async fn stop_vnt_handler(State(state): State<HttpAppState>) -> Json<ApiResponse<()>> {
|
||||
if state.status() == VntStatus::Stopped {
|
||||
async fn stop_vnt_handler(
|
||||
State(state): State<HttpAppState>,
|
||||
Json(req): Json<FileReq>,
|
||||
) -> Json<ApiResponse<()>> {
|
||||
let Some(task_group_manager) = state.task_group_manager(&req.file_name) else {
|
||||
return Json(ApiResponse::error("实例不存在"));
|
||||
};
|
||||
if state.status(&req.file_name) == VntStatus::Stopped {
|
||||
return Json(ApiResponse::error("Vnt stopped"));
|
||||
}
|
||||
state.task_group_manager.stop();
|
||||
// 先中断可能处于注册重试循环中的启动任务,再停止任务组
|
||||
state.abort_start_task(&req.file_name);
|
||||
task_group_manager.stop();
|
||||
|
||||
let _ = fs::write(CURRENT_CONFIG_RECORD, "").await;
|
||||
record_remove_running(&req.file_name).await;
|
||||
Json(ApiResponse::success(()))
|
||||
}
|
||||
|
||||
/// 移除已停止的实例条目(清理启动失败的残留卡片)
|
||||
async fn dismiss_instance_handler(
|
||||
State(state): State<HttpAppState>,
|
||||
Query(req): Query<FileReq>,
|
||||
) -> Json<ApiResponse<()>> {
|
||||
let mut lock = state.inner.lock();
|
||||
match lock.instances.get(&req.file_name) {
|
||||
None => Json(ApiResponse::error("实例不存在")),
|
||||
Some(inst) if inst.status != VntStatus::Stopped => {
|
||||
Json(ApiResponse::error("实例正在运行,不能移除"))
|
||||
}
|
||||
Some(_) => {
|
||||
lock.instances.remove(&req.file_name);
|
||||
Json(ApiResponse::success(()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn restart_vnt_handler(
|
||||
State(state): State<HttpAppState>,
|
||||
Json(req): Json<FileReq>,
|
||||
@@ -668,11 +1134,14 @@ async fn restart_vnt_handler(
|
||||
}
|
||||
|
||||
// 先停止(如果正在运行则停止,否则忽略)
|
||||
if state.status() != VntStatus::Stopped {
|
||||
state.task_group_manager.stop();
|
||||
if state.status(&req.file_name) != VntStatus::Stopped {
|
||||
state.abort_start_task(&req.file_name);
|
||||
if let Some(task_group_manager) = state.task_group_manager(&req.file_name) {
|
||||
task_group_manager.stop();
|
||||
}
|
||||
// 等待停止完成
|
||||
for _ in 0..50 {
|
||||
if state.status() == VntStatus::Stopped {
|
||||
if state.status(&req.file_name) == VntStatus::Stopped {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
@@ -686,11 +1155,37 @@ async fn restart_vnt_handler(
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_info(State(state): State<HttpAppState>) -> Json<ApiResponse<HttpAppInfo>> {
|
||||
let lock = state.inner.lock();
|
||||
let status = lock.status;
|
||||
/// 客户端版本号,与组网状态无关,任何时刻都可获取
|
||||
async fn get_version() -> Json<ApiResponse<String>> {
|
||||
Json(ApiResponse::success(env!("CARGO_PKG_VERSION").to_string()))
|
||||
}
|
||||
|
||||
let info = if let Some(handler) = lock.vnt.as_ref() {
|
||||
async fn get_info(
|
||||
State(state): State<HttpAppState>,
|
||||
Query(req): Query<FileReq>,
|
||||
) -> Json<ApiResponse<HttpAppInfo>> {
|
||||
// 先读当前配置文件(异步),避免持锁跨 await
|
||||
let current_config: Option<StartConfig> =
|
||||
match fs::read_to_string(Path::new(CONFIG_DIR).join(&req.file_name)).await {
|
||||
Ok(content) => toml::from_str(&content).ok(),
|
||||
Err(_) => None,
|
||||
};
|
||||
|
||||
let lock = state.inner.lock();
|
||||
let Some(inst) = lock.instances.get(&req.file_name) else {
|
||||
return Json(ApiResponse::error("实例不存在"));
|
||||
};
|
||||
let status = inst.status;
|
||||
|
||||
// 与启动时的配置快照对比:文件缺失或解析失败也视为已变化
|
||||
let config_changed = status != VntStatus::Stopped
|
||||
&& match (&inst.start_config, ¤t_config) {
|
||||
(Some(base), Some(current)) => base != current,
|
||||
(Some(_), None) => true,
|
||||
(None, _) => false,
|
||||
};
|
||||
|
||||
let info = if let Some(handler) = inst.vnt.as_ref() {
|
||||
let api = &handler.api;
|
||||
let config = api.get_config();
|
||||
let ips = api.client_ips();
|
||||
@@ -738,11 +1233,13 @@ async fn get_info(State(state): State<HttpAppState>) -> Json<ApiResponse<HttpApp
|
||||
compress: config.as_ref().map(|v| v.compress),
|
||||
encrypt: config.as_ref().map(|v| v.password.is_some()),
|
||||
rtx: config.as_ref().map(|v| v.rtx),
|
||||
config_changed,
|
||||
}
|
||||
} else {
|
||||
HttpAppInfo {
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
status,
|
||||
config_changed,
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
@@ -795,7 +1292,13 @@ async fn list_configs() -> Json<ApiResponse<Vec<ConfigSummary>>> {
|
||||
|
||||
async fn save_config(Json(req): Json<SaveConfigReq>) -> Json<ApiResponse<()>> {
|
||||
// 验证配置格式
|
||||
if let Err(e) = toml::from_str::<StartConfig>(&req.config) {
|
||||
let parsed = toml::from_str::<StartConfig>(&req.config).and_then(|config| {
|
||||
config
|
||||
.reject_legacy_no_tun()
|
||||
.map(|_| config)
|
||||
.map_err(serde::de::Error::custom)
|
||||
});
|
||||
if let Err(e) = parsed {
|
||||
log::warn!("Failed to parse configuration: {:?}", e);
|
||||
return Json(ApiResponse::error(format!("Invalid TOML format: {}", e)));
|
||||
}
|
||||
@@ -815,6 +1318,11 @@ async fn save_config(Json(req): Json<SaveConfigReq>) -> Json<ApiResponse<()>> {
|
||||
return Json(ApiResponse::error("Invalid file name"));
|
||||
}
|
||||
|
||||
let file_name = match normalize_config_file_name(file_name) {
|
||||
Ok(name) => name,
|
||||
Err(msg) => return Json(ApiResponse::error(msg)),
|
||||
};
|
||||
|
||||
let target_path = Path::new(CONFIG_DIR).join(&file_name);
|
||||
|
||||
match fs::write(&target_path, &req.config).await {
|
||||
@@ -848,8 +1356,10 @@ async fn delete_config(
|
||||
return Json(ApiResponse::error("Invalid file name"));
|
||||
}
|
||||
{
|
||||
if let Some(vnt) = &state.inner.lock().vnt
|
||||
&& vnt.config_file_name == req.file_name
|
||||
let lock = state.inner.lock();
|
||||
// 实例存在且有运行内容(已运行或非 Stopped)即视为占用
|
||||
if let Some(inst) = lock.instances.get(&req.file_name)
|
||||
&& (inst.vnt.is_some() || inst.status != VntStatus::Stopped)
|
||||
{
|
||||
return Json(ApiResponse::error("此配置已被使用,不能删除"));
|
||||
}
|
||||
@@ -868,6 +1378,7 @@ async fn delete_config(
|
||||
}
|
||||
|
||||
fn convert_config(cfg: StartConfig) -> anyhow::Result<CoreConfig> {
|
||||
cfg.reject_legacy_no_tun()?;
|
||||
let server_addrs: Vec<ProtocolAddress> = cfg
|
||||
.server
|
||||
.iter()
|
||||
@@ -927,18 +1438,20 @@ fn convert_config(cfg: StartConfig) -> anyhow::Result<CoreConfig> {
|
||||
device_id,
|
||||
device_name,
|
||||
tun_name: cfg.tun_name,
|
||||
outbound_interface: cfg.outbound_interface,
|
||||
password: cfg.password,
|
||||
cert_mode,
|
||||
input: cfg.input,
|
||||
output: cfg.output,
|
||||
no_nat: cfg.no_nat,
|
||||
no_tun: cfg.no_tun,
|
||||
device_mode: cfg.device_mode,
|
||||
mtu: cfg.mtu,
|
||||
port_mapping,
|
||||
allow_port_mapping: cfg.allow_mapping,
|
||||
udp_stun,
|
||||
tcp_stun,
|
||||
fec: cfg.fec,
|
||||
tunnel_port: cfg.tunnel_port,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -966,8 +1479,17 @@ async fn shutdown_signal() {
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_peers(State(state): State<HttpAppState>) -> Json<ApiResponse<Vec<HttpClientItem>>> {
|
||||
let api = state.inner.lock().vnt.as_ref().map(|v| v.api.clone());
|
||||
async fn get_peers(
|
||||
State(state): State<HttpAppState>,
|
||||
Query(req): Query<FileReq>,
|
||||
) -> Json<ApiResponse<Vec<HttpClientItem>>> {
|
||||
let api = state
|
||||
.inner
|
||||
.lock()
|
||||
.instances
|
||||
.get(&req.file_name)
|
||||
.and_then(|inst| inst.vnt.as_ref())
|
||||
.map(|v| v.api.clone());
|
||||
|
||||
let Some(api) = api else {
|
||||
return Json(ApiResponse::error("VNT not running"));
|
||||
@@ -1014,6 +1536,7 @@ async fn get_peers(State(state): State<HttpAppState>) -> Json<ApiResponse<Vec<Ht
|
||||
protocol: route.route_key().protocol().to_string(),
|
||||
metric: route.metric(),
|
||||
rtt: route.rtt(),
|
||||
loss_rate: route.loss_rate(),
|
||||
})
|
||||
};
|
||||
|
||||
@@ -1077,10 +1600,17 @@ async fn get_peers(State(state): State<HttpAppState>) -> Json<ApiResponse<Vec<Ht
|
||||
Json(ApiResponse::success(items))
|
||||
}
|
||||
|
||||
async fn get_routes(State(state): State<HttpAppState>) -> Json<ApiResponse<Vec<HttpRouteItem>>> {
|
||||
async fn get_routes(
|
||||
State(state): State<HttpAppState>,
|
||||
Query(req): Query<FileReq>,
|
||||
) -> Json<ApiResponse<Vec<HttpRouteItem>>> {
|
||||
let lock = state.inner.lock();
|
||||
|
||||
let Some(handler) = lock.vnt.as_ref() else {
|
||||
let Some(handler) = lock
|
||||
.instances
|
||||
.get(&req.file_name)
|
||||
.and_then(|inst| inst.vnt.as_ref())
|
||||
else {
|
||||
return Json(ApiResponse::error("VNT not running"));
|
||||
};
|
||||
|
||||
@@ -1096,6 +1626,7 @@ async fn get_routes(State(state): State<HttpAppState>) -> Json<ApiResponse<Vec<H
|
||||
protocol: v.route_key().protocol().to_string(),
|
||||
metric: v.metric(),
|
||||
rtt: v.rtt(),
|
||||
loss_rate: v.loss_rate(),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
@@ -1103,3 +1634,335 @@ async fn get_routes(State(state): State<HttpAppState>) -> Json<ApiResponse<Vec<H
|
||||
|
||||
Json(ApiResponse::success(items))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ipc_request_uses_in_process_router() {
|
||||
let service = VntService {
|
||||
router: api_router(new_test_state(), ServiceRuntime::StandaloneWeb),
|
||||
};
|
||||
let response = service.request("GET", "/api/version", None).await.unwrap();
|
||||
assert_eq!(response["code"], 0);
|
||||
assert!(
|
||||
response["data"]
|
||||
.as_str()
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
);
|
||||
|
||||
let response = service.request("GET", "/api/runtime", None).await.unwrap();
|
||||
assert_eq!(response["code"], 0);
|
||||
assert_eq!(response["data"], "standalone_web");
|
||||
|
||||
let desktop_service = VntService {
|
||||
router: api_router(new_test_state(), ServiceRuntime::DesktopWeb),
|
||||
};
|
||||
let response = desktop_service
|
||||
.request("GET", "/api/runtime", None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response["code"], 0);
|
||||
assert_eq!(response["data"], "desktop_web");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_http_api_requires_bearer_token() {
|
||||
let token = "test-token-with-enough-entropy".to_string();
|
||||
let app = http_router(
|
||||
api_router(new_test_state(), ServiceRuntime::StandaloneWeb),
|
||||
token.clone(),
|
||||
);
|
||||
let unauthorized = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.uri("/api/version")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let authorized = app
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.uri("/api/version")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(authorized.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_config_file_name() {
|
||||
// 无扩展名补 .toml
|
||||
assert_eq!(
|
||||
normalize_config_file_name("myconfig".to_string()).unwrap(),
|
||||
"myconfig.toml"
|
||||
);
|
||||
// 已是 .toml 保持不变
|
||||
assert_eq!(
|
||||
normalize_config_file_name("a.toml".to_string()).unwrap(),
|
||||
"a.toml"
|
||||
);
|
||||
// 其他扩展名拒绝(list_configs 只列 *.toml,保存了也不可见)
|
||||
assert!(normalize_config_file_name("a.txt".to_string()).is_err());
|
||||
assert!(normalize_config_file_name("a.json".to_string()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_static_path_allows_normal_paths() {
|
||||
assert_eq!(
|
||||
resolve_static_path("index.html"),
|
||||
Some(PathBuf::from("static").join("index.html"))
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_static_path("css/style.css"),
|
||||
Some(PathBuf::from("static").join("css").join("style.css"))
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_static_path("./index.html"),
|
||||
Some(PathBuf::from("static").join("index.html"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_static_path_rejects_traversal() {
|
||||
assert!(resolve_static_path("../Cargo.toml").is_none());
|
||||
assert!(resolve_static_path("a/../../Cargo.toml").is_none());
|
||||
assert!(resolve_static_path("/etc/passwd").is_none());
|
||||
assert!(resolve_static_path("..").is_none());
|
||||
// Windows 下反斜杠也是路径分隔符
|
||||
#[cfg(windows)]
|
||||
{
|
||||
assert!(resolve_static_path("..\\..\\Cargo.toml").is_none());
|
||||
assert!(resolve_static_path("C:/Windows/win.ini").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
fn new_test_state() -> HttpAppState {
|
||||
HttpAppState {
|
||||
inner: Arc::new(Mutex::new(HttpAppStateInner::default())),
|
||||
}
|
||||
}
|
||||
|
||||
fn new_test_config() -> StartConfig {
|
||||
StartConfig {
|
||||
config_name: None,
|
||||
server: Vec::new(),
|
||||
cert_mode: None,
|
||||
network_code: "test".to_string(),
|
||||
device_id: Some("device-a".to_string()),
|
||||
device_name: None,
|
||||
tun_name: None,
|
||||
outbound_interface: None,
|
||||
ip: None,
|
||||
password: None,
|
||||
no_punch: false,
|
||||
compress: false,
|
||||
rtx: false,
|
||||
fec: false,
|
||||
input: Vec::new(),
|
||||
output: Vec::new(),
|
||||
no_nat: false,
|
||||
// 默认无网卡,避免无关用例意外触发 tun_name 冲突
|
||||
device_mode: DeviceMode::No,
|
||||
legacy_no_tun: None,
|
||||
mtu: None,
|
||||
port_mapping: Vec::new(),
|
||||
allow_mapping: false,
|
||||
udp_stun: Vec::new(),
|
||||
tcp_stun: Vec::new(),
|
||||
tunnel_port: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_device_mode_config_and_legacy_rejection() {
|
||||
let base = r#"server = ["quic://127.0.0.1:29872"]
|
||||
network_code = "test"
|
||||
"#;
|
||||
let default_cfg: StartConfig = toml::from_str(base).unwrap();
|
||||
assert_eq!(default_cfg.device_mode, DeviceMode::Tun);
|
||||
|
||||
let tap_cfg: StartConfig =
|
||||
toml::from_str(&format!("{base}device_mode = \"tap\"\n")).unwrap();
|
||||
assert_eq!(tap_cfg.device_mode, DeviceMode::Tap);
|
||||
|
||||
let legacy: StartConfig = toml::from_str(&format!("{base}no_tun = true\n")).unwrap();
|
||||
assert!(legacy.reject_legacy_no_tun().is_err());
|
||||
}
|
||||
|
||||
/// 两个实例同时处于 Starting 互不影响
|
||||
#[test]
|
||||
fn test_two_instances_starting_independent() {
|
||||
let state = new_test_state();
|
||||
state.starting("a.toml").unwrap();
|
||||
state.starting("b.toml").unwrap();
|
||||
state.record_log("a.toml", "a 的日志");
|
||||
state.record_log("b.toml", "b 的日志");
|
||||
|
||||
assert_eq!(state.status("a.toml"), VntStatus::Starting);
|
||||
assert_eq!(state.status("b.toml"), VntStatus::Starting);
|
||||
|
||||
// a 启动失败停止,b 的状态和日志不受影响
|
||||
state.record_log_and_stopped("a.toml", "启动失败");
|
||||
assert_eq!(state.status("a.toml"), VntStatus::Stopped);
|
||||
assert_eq!(state.status("b.toml"), VntStatus::Starting);
|
||||
|
||||
let lock = state.inner.lock();
|
||||
let a = lock.instances.get("a.toml").unwrap();
|
||||
assert!(a.start_logs.iter().any(|l| l.contains("启动失败")));
|
||||
let b = lock.instances.get("b.toml").unwrap();
|
||||
assert_eq!(b.start_logs.len(), 1);
|
||||
assert!(b.start_logs[0].contains("b 的日志"));
|
||||
}
|
||||
|
||||
/// 移除已停止实例:Stopped 可移除,Starting 拒绝
|
||||
#[tokio::test]
|
||||
async fn test_dismiss_instance() {
|
||||
let state = new_test_state();
|
||||
state.starting("a.toml").unwrap();
|
||||
state.record_log_and_stopped("a.toml", "启动失败");
|
||||
state.starting("b.toml").unwrap();
|
||||
|
||||
// Starting 中的实例不能移除
|
||||
let resp = dismiss_instance_handler(
|
||||
State(state.clone()),
|
||||
Query(FileReq {
|
||||
file_name: "b.toml".to_string(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.code, -1);
|
||||
assert!(state.inner.lock().instances.contains_key("b.toml"));
|
||||
|
||||
// 已停止(启动失败残留)的实例可以移除
|
||||
let resp = dismiss_instance_handler(
|
||||
State(state.clone()),
|
||||
Query(FileReq {
|
||||
file_name: "a.toml".to_string(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.code, 0);
|
||||
assert!(!state.inner.lock().instances.contains_key("a.toml"));
|
||||
|
||||
// 不存在的实例报错
|
||||
let resp = dismiss_instance_handler(
|
||||
State(state.clone()),
|
||||
Query(FileReq {
|
||||
file_name: "nope.toml".to_string(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.code, -1);
|
||||
}
|
||||
|
||||
/// 同一 file_name 重复 starting 报错
|
||||
#[test]
|
||||
fn test_duplicate_starting_same_file() {
|
||||
let state = new_test_state();
|
||||
state.starting("a.toml").unwrap();
|
||||
assert!(state.starting("a.toml").is_err());
|
||||
// 不同 file_name 不受影响
|
||||
state.starting("b.toml").unwrap();
|
||||
}
|
||||
|
||||
/// device_id 相同(含双方都为 None)且同服务器同组网时冲突;
|
||||
/// 不同服务器或不同 network_code 时允许相同 device_id
|
||||
#[test]
|
||||
fn test_conflict_same_device_id() {
|
||||
let running = new_test_config();
|
||||
// 相同 device_id(双方 server 均为空,视为同范围)
|
||||
let new = new_test_config();
|
||||
assert!(check_config_conflict(&new, &[&running]).is_err());
|
||||
// 双方都不指定 device_id(缺省会用同一 machine_uid)也算冲突
|
||||
let mut a = new_test_config();
|
||||
a.device_id = None;
|
||||
let mut b = new_test_config();
|
||||
b.device_id = None;
|
||||
assert!(check_config_conflict(&b, &[&a]).is_err());
|
||||
// 不同 device_id 不冲突
|
||||
let mut c = new_test_config();
|
||||
c.device_id = Some("device-c".to_string());
|
||||
assert!(check_config_conflict(&c, &[&running]).is_ok());
|
||||
// 相同 device_id 但 network_code 不同 → 不冲突
|
||||
let mut d = new_test_config();
|
||||
d.network_code = "other-net".to_string();
|
||||
assert!(check_config_conflict(&d, &[&running]).is_ok());
|
||||
// 相同 device_id 相同 network_code 但服务器不同 → 不冲突
|
||||
let mut e_running = new_test_config();
|
||||
e_running.server = vec!["server1:29870".to_string()];
|
||||
let mut e = new_test_config();
|
||||
e.server = vec!["server2:29870".to_string()];
|
||||
assert!(check_config_conflict(&e, &[&e_running]).is_ok());
|
||||
// 相同 device_id 相同 network_code 且服务器有交集 → 冲突
|
||||
let mut f = new_test_config();
|
||||
f.server = vec!["server1:29870".to_string(), "server3:29870".to_string()];
|
||||
assert!(check_config_conflict(&f, &[&e_running]).is_err());
|
||||
}
|
||||
|
||||
/// tunnel_port 都为 Some 且相等时冲突
|
||||
#[test]
|
||||
fn test_conflict_same_tunnel_port() {
|
||||
let mut running = new_test_config();
|
||||
running.device_id = Some("d1".to_string());
|
||||
running.tunnel_port = Some(12345);
|
||||
let mut new = new_test_config();
|
||||
new.device_id = Some("d2".to_string());
|
||||
new.tunnel_port = Some(12345);
|
||||
assert!(check_config_conflict(&new, &[&running]).is_err());
|
||||
// 一方未指定不冲突
|
||||
let mut new_none = new_test_config();
|
||||
new_none.device_id = Some("d2".to_string());
|
||||
assert!(check_config_conflict(&new_none, &[&running]).is_ok());
|
||||
// 端口不同不冲突
|
||||
let mut new_other = new_test_config();
|
||||
new_other.device_id = Some("d2".to_string());
|
||||
new_other.tunnel_port = Some(23456);
|
||||
assert!(check_config_conflict(&new_other, &[&running]).is_ok());
|
||||
}
|
||||
|
||||
/// Starting 状态下执行停止:必须中断注册重试循环并迁移到 Stopped。
|
||||
/// 复现 bug 场景——服务器不可达时启动任务陷在无限重试里,
|
||||
/// 不中断启动任务则状态永远卡在 Starting。
|
||||
#[tokio::test]
|
||||
async fn test_stop_during_starting() {
|
||||
let state = new_test_state();
|
||||
let file_name = "a.toml";
|
||||
state.starting(file_name).unwrap();
|
||||
|
||||
// 模拟启动任务:注册一直失败、5 秒重试的无限循环
|
||||
let state_clone = state.clone();
|
||||
let file_name_owned = file_name.to_string();
|
||||
let on_error_guard = defer(move || {
|
||||
state_clone.starting_to_stopped(&file_name_owned);
|
||||
});
|
||||
let handle = tokio::spawn(async move {
|
||||
let _on_error_guard = on_error_guard;
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
}
|
||||
});
|
||||
state.set_start_handle(file_name, handle);
|
||||
|
||||
assert_eq!(state.status(file_name), VntStatus::Starting);
|
||||
state.abort_start_task(file_name);
|
||||
|
||||
// abort 生效后 defer 触发,状态应迁移到 Stopped
|
||||
for _ in 0..100 {
|
||||
if state.status(file_name) == VntStatus::Stopped {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
assert_eq!(state.status(file_name), VntStatus::Stopped);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2884 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>VNT Dashboard</title>
|
||||
<link rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect x='0' y='0' width='64' height='64' rx='16' fill='%230f172a'/%3E%3Cpath d='M16 20 L32 48 L48 20' fill='none' stroke='%233b82f6' stroke-width='6' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='16' cy='20' r='6' fill='%2360a5fa'/%3E%3Ccircle cx='48' cy='20' r='6' fill='%2360a5fa'/%3E%3Ccircle cx='32' cy='48' r='6' fill='%2322c55e'/%3E%3C/svg%3E"
|
||||
type="image/svg+xml">
|
||||
<script src="tailwindcss3.4.17.js.gz"></script>
|
||||
<script src="vue.global.prod.js.gz"></script>
|
||||
<script src="vue-router.global.prod.js.gz"></script>
|
||||
|
||||
<style>
|
||||
[v-cloak] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #0f172a;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.glass-panel {
|
||||
background: rgba(30, 41, 59, 0.7);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.input-dark {
|
||||
background-color: #1e293b;
|
||||
border-color: #334155;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.input-dark:focus {
|
||||
border-color: #3b82f6;
|
||||
outline: none;
|
||||
ring: 2px;
|
||||
}
|
||||
|
||||
/* Tooltip CSS (保留用于表格内的静态 tooltip) */
|
||||
.tooltip {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tooltip .tooltip-text {
|
||||
visibility: hidden;
|
||||
width: 140px;
|
||||
background-color: rgba(0, 0, 0, 0.9);
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
border-radius: 6px;
|
||||
padding: 6px 4px;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
bottom: 125%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
border: 1px solid #475569;
|
||||
}
|
||||
|
||||
.tooltip .tooltip-text::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
margin-left: -5px;
|
||||
border-width: 5px;
|
||||
border-style: solid;
|
||||
border-color: rgba(0, 0, 0, 0.9) transparent transparent transparent;
|
||||
}
|
||||
|
||||
.tooltip:hover .tooltip-text {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 路由切换动画 */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(71, 85, 105, 0.8);
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(100, 116, 139, 0.9);
|
||||
}
|
||||
|
||||
/* Firefox 滚动条样式 */
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(71, 85, 105, 0.8) rgba(15, 23, 42, 0.5);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="h-screen overflow-hidden flex flex-col font-sans">
|
||||
<div id="app" v-cloak class="flex h-full">
|
||||
<!-- 侧边栏 -->
|
||||
<aside
|
||||
class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0"
|
||||
>
|
||||
<div
|
||||
class="p-6 flex items-center justify-center border-b border-slate-800"
|
||||
>
|
||||
<h1 class="text-2xl font-bold text-blue-500 tracking-wider">
|
||||
VNT<span class="text-slate-400 text-sm ml-2">Web</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<nav class="flex-1 p-4 space-y-2">
|
||||
<!-- 使用 router-link 和 v-slot 自定义渲染按钮 -->
|
||||
<router-link
|
||||
to="/general"
|
||||
custom
|
||||
v-slot="{ navigate, isActive }"
|
||||
>
|
||||
<button
|
||||
@click="navigate"
|
||||
:class="navClass(isActive)"
|
||||
class="w-full flex items-center p-3 rounded-lg transition-all duration-200"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 mr-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
|
||||
></path>
|
||||
</svg>
|
||||
通用
|
||||
</button>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/config"
|
||||
custom
|
||||
v-slot="{ navigate, isActive }"
|
||||
>
|
||||
<button
|
||||
@click="navigate"
|
||||
:class="navClass(isActive)"
|
||||
class="w-full flex items-center p-3 rounded-lg transition-all duration-200"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 mr-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
></path>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
></path>
|
||||
</svg>
|
||||
配置
|
||||
</button>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/peers"
|
||||
custom
|
||||
v-slot="{ navigate, isActive }"
|
||||
>
|
||||
<button
|
||||
@click="navigate"
|
||||
:class="navClass(isActive)"
|
||||
class="w-full flex items-center p-3 rounded-lg transition-all duration-200"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 mr-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
></path>
|
||||
</svg>
|
||||
设备列表
|
||||
</button>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/routes"
|
||||
custom
|
||||
v-slot="{ navigate, isActive }"
|
||||
>
|
||||
<button
|
||||
@click="navigate"
|
||||
:class="navClass(isActive)"
|
||||
class="w-full flex items-center p-3 rounded-lg transition-all duration-200"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 mr-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 01-.806-.98l-3.747-1.874O12 7m3 13V7m-3 0l3 3"
|
||||
></path>
|
||||
</svg>
|
||||
路由
|
||||
</button>
|
||||
</router-link>
|
||||
</nav>
|
||||
|
||||
<div class="p-4 text-xs text-slate-500 text-center">
|
||||
Client: v{{ info.version }}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main
|
||||
class="flex-1 flex flex-col bg-slate-900/50 relative overflow-hidden"
|
||||
>
|
||||
<!-- Header -->
|
||||
<header
|
||||
class="h-16 glass-panel border-b border-slate-700 flex items-center justify-between px-8 z-10 shrink-0"
|
||||
>
|
||||
<div class="flex items-center space-x-6">
|
||||
<div
|
||||
class="flex items-center space-x-2 bg-slate-800 rounded-full px-3 py-1 border border-slate-700"
|
||||
>
|
||||
<span
|
||||
class="w-2.5 h-2.5 rounded-full"
|
||||
:class="info.status === 'running' ? 'bg-green-500 animate-pulse' : (info.status === 'starting' ? 'bg-yellow-500 animate-pulse' : 'bg-red-500')"
|
||||
></span>
|
||||
<span class="text-sm font-medium"
|
||||
>{{ info.status === 'running' ? '已运行' :
|
||||
(info.status === 'starting' ? '启动中...' :
|
||||
'未启动') }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="info.status === 'running'"
|
||||
class="flex items-center space-x-2 bg-slate-800 rounded-full px-3 py-1 border border-slate-700"
|
||||
:title="serverStatusText"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
:class="isServerConnected ? 'text-green-400' : 'text-red-400'"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
|
||||
></path>
|
||||
</svg>
|
||||
<span
|
||||
class="text-sm font-medium"
|
||||
:class="isServerConnected ? 'text-green-400' : 'text-red-400'"
|
||||
>服务器: {{ isServerConnected ? '已连接' :
|
||||
'未连接' }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="info.ip"
|
||||
class="flex items-center space-x-2 text-slate-300"
|
||||
>
|
||||
<span
|
||||
class="font-mono text-blue-400 font-bold bg-blue-900/20 px-2 py-0.5 rounded"
|
||||
>{{ info.ip }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2 text-slate-400">
|
||||
<span class="text-sm">设备:</span>
|
||||
<span class="font-bold text-white"
|
||||
>{{ info.name || '' }}</span
|
||||
>
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded bg-slate-800 text-slate-500"
|
||||
title="Device ID"
|
||||
>{{ info.device_id.substring(0, 8) }}...</span
|
||||
>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Router View with Transition -->
|
||||
<div class="flex-1 overflow-auto scrollbar-hide p-8 relative">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component"/>
|
||||
</transition>
|
||||
</router-view>
|
||||
</div>
|
||||
|
||||
<!-- 启动日志弹窗 (Global) -->
|
||||
<div
|
||||
v-if="showStartLog"
|
||||
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
||||
>
|
||||
<div
|
||||
class="bg-slate-900/95 border border-slate-700 rounded-xl w-full max-w-2xl flex flex-col shadow-2xl overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="px-6 py-4 border-b border-slate-700 flex justify-between items-center bg-slate-800/50"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div
|
||||
v-if="startStatus === 'starting'"
|
||||
class="w-3 h-3 bg-blue-500 rounded-full animate-ping"
|
||||
></div>
|
||||
<div
|
||||
v-else-if="startStatus === 'running'"
|
||||
class="w-3 h-3 bg-green-500 rounded-full"
|
||||
></div>
|
||||
<div
|
||||
v-else
|
||||
class="w-3 h-3 bg-red-500 rounded-full"
|
||||
></div>
|
||||
<h3 class="text-lg font-bold text-white">
|
||||
{{ startStatus === 'starting' ?
|
||||
'正在启动组网...' : (startStatus ===
|
||||
'running' ? '启动成功' : '启动失败') }}
|
||||
</h3>
|
||||
</div>
|
||||
<span
|
||||
class="text-xs font-mono text-slate-500 uppercase tracking-widest"
|
||||
>{{ startStatus }}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
ref="logContainer"
|
||||
class="flex-1 p-6 h-80 overflow-y-auto scrollbar-hide font-mono text-sm space-y-2 bg-black/20"
|
||||
>
|
||||
<div
|
||||
v-for="(log, idx) in startLogs"
|
||||
:key="idx"
|
||||
class="flex space-x-3 animate-in fade-in slide-in-from-left-2"
|
||||
>
|
||||
<span class="text-blue-500 shrink-0">>>></span>
|
||||
<span class="text-slate-300 break-all"
|
||||
>{{ log }}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-if="startStatus === 'starting'"
|
||||
class="text-blue-400 animate-pulse italic mt-4"
|
||||
>
|
||||
等待后续步骤...
|
||||
</div>
|
||||
<div
|
||||
v-if="startStatus === 'stopped' && startLogs.length > 0"
|
||||
class="p-3 bg-red-900/20 border border-red-900/50 rounded-lg text-red-400 mt-4"
|
||||
>
|
||||
<strong>启动失败:</strong>
|
||||
请检查配置或网络连接。
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="p-4 border-t border-slate-700 flex justify-end space-x-3 bg-slate-800/50"
|
||||
>
|
||||
<button
|
||||
v-if="startStatus === 'starting'"
|
||||
@click="cancelStart"
|
||||
class="px-6 py-2 bg-slate-700 hover:bg-red-600 text-white rounded-lg transition-colors font-medium"
|
||||
>
|
||||
取消组网
|
||||
</button>
|
||||
<button
|
||||
v-if="startStatus === 'stopped' || startStatus === 'running'"
|
||||
@click="showStartLog = false"
|
||||
class="px-6 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors font-medium"
|
||||
>
|
||||
关闭窗口
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tooltip (Global Teleport) -->
|
||||
<teleport to="body">
|
||||
<div
|
||||
v-if="tooltipState.show"
|
||||
:style="{ top: tooltipState.y + 'px', left: tooltipState.x + 'px' }"
|
||||
class="fixed z-[9999] transform -translate-x-1/2 mt-1"
|
||||
@mouseenter="onTooltipEnter"
|
||||
@mouseleave="onTooltipLeave"
|
||||
>
|
||||
<div
|
||||
class="w-auto min-w-[260px] max-w-[320px] text-left p-4 bg-slate-800 border border-slate-600 shadow-2xl rounded-lg text-sm text-slate-200"
|
||||
>
|
||||
<div
|
||||
class="absolute -top-2 left-1/2 -translate-x-1/2 w-4 h-4 bg-slate-800 border-t border-l border-slate-600 transform rotate-45"
|
||||
></div>
|
||||
<div
|
||||
class="flex justify-between items-center border-b border-slate-600 pb-2 mb-2 relative z-10"
|
||||
>
|
||||
<span
|
||||
class="text-slate-400 text-xs uppercase font-bold"
|
||||
>NAT Type</span
|
||||
>
|
||||
<span
|
||||
class="text-green-400 font-bold bg-green-900/30 px-2 py-0.5 rounded text-xs border border-green-800"
|
||||
>{{ tooltipState.info.nat_type }}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-if="tooltipState.info.public_ips && tooltipState.info.public_ips.length > 0"
|
||||
class="mb-3 relative z-10"
|
||||
>
|
||||
<span class="text-slate-400 text-xs block mb-1"
|
||||
>Public IPv4:</span
|
||||
>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="pip in tooltipState.info.public_ips"
|
||||
:key="pip"
|
||||
class="text-xs bg-slate-700 text-slate-200 px-1.5 py-0.5 rounded border border-slate-600"
|
||||
>{{ pip }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="tooltipState.info.ipv6"
|
||||
class="relative z-10"
|
||||
>
|
||||
<span class="text-slate-400 text-xs block mb-1"
|
||||
>IPv6:</span
|
||||
>
|
||||
<div
|
||||
class="text-slate-200 text-xs break-all whitespace-normal leading-relaxed bg-slate-900/50 p-1.5 rounded border border-slate-700/50"
|
||||
>
|
||||
{{ tooltipState.info.ipv6 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- ================= TEMPLATES ================= -->
|
||||
|
||||
<!-- 1. General (通用) -->
|
||||
<template id="tpl-general">
|
||||
<div class="space-y-6 max-w-5xl mx-auto">
|
||||
<div class="glass-panel rounded-xl p-6 shadow-lg">
|
||||
<h2
|
||||
class="text-xl font-bold mb-6 text-white border-l-4 border-blue-500 pl-3"
|
||||
>
|
||||
运行控制
|
||||
</h2>
|
||||
<div class="flex items-end space-x-4">
|
||||
<div class="flex-1">
|
||||
<label
|
||||
class="block text-sm font-medium text-slate-400 mb-2"
|
||||
>选择配置</label
|
||||
>
|
||||
<select
|
||||
v-model="localSelectedConfig"
|
||||
:disabled="info.status === 'starting'"
|
||||
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-4 py-2.5 text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<option value="" disabled>请选择配置...</option>
|
||||
<option
|
||||
v-for="cfg in configList"
|
||||
:key="cfg.file_name"
|
||||
:value="cfg.file_name"
|
||||
>
|
||||
{{ cfg.config_name || cfg.file_name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
v-if="info.status === 'running'"
|
||||
@click="handleRestart"
|
||||
:disabled="loading || !localSelectedConfig"
|
||||
class="px-6 py-2.5 rounded-lg font-bold text-white shadow-lg transition-transform active:scale-95 flex items-center disabled:opacity-50 disabled:cursor-not-allowed bg-blue-500 hover:bg-blue-600"
|
||||
>
|
||||
<span v-if="loading" class="mr-2 animate-spin">⟳</span>
|
||||
重启
|
||||
</button>
|
||||
<button
|
||||
@click="handleToggle"
|
||||
:disabled="loading || (!localSelectedConfig && info.status !== 'running' && info.status !== 'starting')"
|
||||
:class="(info.status === 'running' || info.status === 'starting') ? 'bg-red-500 hover:bg-red-600' : 'bg-green-500 hover:bg-green-600'"
|
||||
class="px-8 py-2.5 rounded-lg font-bold text-white shadow-lg transition-transform active:scale-95 flex items-center disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span v-if="loading" class="mr-2 animate-spin"
|
||||
>⟳</span
|
||||
>
|
||||
{{ (info.status === 'running' || info.status ===
|
||||
'starting') ? '停止运行' : '启动运行' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<div
|
||||
class="glass-panel p-5 rounded-xl flex flex-col items-center justify-center border-t-4 border-t-blue-500"
|
||||
>
|
||||
<span class="text-slate-400 text-sm mb-1"
|
||||
>在线设备</span
|
||||
>
|
||||
<span class="text-3xl font-bold text-blue-400"
|
||||
>{{ info.online_client_num }}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="glass-panel p-5 rounded-xl flex flex-col items-center justify-center border-t-4 border-t-green-500"
|
||||
>
|
||||
<span class="text-slate-400 text-sm mb-1"
|
||||
>直连设备</span
|
||||
>
|
||||
<span class="text-3xl font-bold text-green-400"
|
||||
>{{ info.direct_client_num }}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="glass-panel p-5 rounded-xl flex flex-col items-center justify-center border-t-4 border-t-slate-500"
|
||||
>
|
||||
<span class="text-slate-400 text-sm mb-1"
|
||||
>离线设备</span
|
||||
>
|
||||
<span class="text-3xl font-bold text-slate-400"
|
||||
>{{ info.offline_client_num }}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="glass-panel p-5 rounded-xl flex flex-col items-center justify-center border-t-4 border-t-yellow-500"
|
||||
>
|
||||
<span class="text-slate-400 text-sm mb-1"
|
||||
>当前配置</span
|
||||
>
|
||||
<span
|
||||
class="text-lg font-medium text-yellow-400 truncate w-full text-center px-2"
|
||||
:title="info.current_config_file"
|
||||
>
|
||||
{{ info.current_config_name || '-' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-panel rounded-xl p-6 shadow-lg">
|
||||
<h2 class="text-lg font-bold mb-4 text-white">网络详情</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div
|
||||
class="flex justify-between border-b border-slate-700 pb-2"
|
||||
>
|
||||
<span class="text-slate-400">虚拟 IP / 掩码</span>
|
||||
<span class="font-mono text-white"
|
||||
>{{ info.ip || '-' }} / {{ info.prefix_len ||
|
||||
'-' }}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between border-b border-slate-700 pb-2"
|
||||
>
|
||||
<span class="text-slate-400">网关</span>
|
||||
<span class="font-mono text-white"
|
||||
>{{ info.gateway || '-' }}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between border-b border-slate-700 pb-2"
|
||||
>
|
||||
<span class="text-slate-400">网络编号</span>
|
||||
<span class="font-mono text-white"
|
||||
>{{ info.network_code || '-' }}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between border-b border-slate-700 pb-2"
|
||||
>
|
||||
<span class="text-slate-400">MTU</span>
|
||||
<span class="font-mono text-white"
|
||||
>{{ info.mtu || '' }}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between border-b border-slate-700 pb-2"
|
||||
>
|
||||
<span class="text-slate-400">NAT 类型</span>
|
||||
<span class="font-mono text-blue-300"
|
||||
>{{ info.nat_type || 'Unknown' }}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between border-b border-slate-700 pb-2"
|
||||
>
|
||||
<span class="text-slate-400">Public IPv6</span>
|
||||
<span
|
||||
class="font-mono text-white truncate max-w-[200px]"
|
||||
:title="info.public_ipv6"
|
||||
>{{ info.public_ipv6 || '-' }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div class="flex items-center space-x-2 bg-slate-800/50 rounded-lg px-3 py-2">
|
||||
<span class="w-2 h-2 rounded-full" :class="info.encrypt ? 'bg-green-500' : 'bg-slate-600'"></span>
|
||||
<span class="text-sm text-slate-300">加密</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 bg-slate-800/50 rounded-lg px-3 py-2">
|
||||
<span class="w-2 h-2 rounded-full" :class="info.compress ? 'bg-green-500' : 'bg-slate-600'"></span>
|
||||
<span class="text-sm text-slate-300">压缩</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 bg-slate-800/50 rounded-lg px-3 py-2">
|
||||
<span class="w-2 h-2 rounded-full" :class="info.fec ? 'bg-green-500' : 'bg-slate-600'"></span>
|
||||
<span class="text-sm text-slate-300">FEC纠错</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 bg-slate-800/50 rounded-lg px-3 py-2">
|
||||
<span class="w-2 h-2 rounded-full" :class="info.rtx ? 'bg-green-500' : 'bg-slate-600'"></span>
|
||||
<span class="text-sm text-slate-300">QUIC传输</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<span class="text-slate-400 text-sm block mb-2"
|
||||
>Public IPv4s</span
|
||||
>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="pip in info.public_ipv4s"
|
||||
:key="pip"
|
||||
class="px-2 py-1 bg-slate-800 rounded text-xs font-mono text-green-300 border border-slate-600"
|
||||
>{{ pip }}</span
|
||||
>
|
||||
<span
|
||||
v-if="!info.public_ipv4s || info.public_ipv4s.length === 0"
|
||||
class="text-slate-600 text-xs"
|
||||
>无</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-panel rounded-xl p-6 shadow-lg">
|
||||
<h2 class="text-lg font-bold mb-4 text-white">
|
||||
服务器连接列表
|
||||
</h2>
|
||||
<div
|
||||
class="overflow-auto max-h-[400px] custom-scrollbar rounded-lg border border-slate-700"
|
||||
>
|
||||
<table class="min-w-full divide-y divide-slate-700">
|
||||
<thead class="bg-slate-800">
|
||||
<tr>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider"
|
||||
>
|
||||
地址
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider"
|
||||
>
|
||||
状态
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider"
|
||||
>
|
||||
延迟
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider"
|
||||
>
|
||||
版本
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
class="divide-y divide-slate-700 bg-slate-900/30"
|
||||
>
|
||||
<tr
|
||||
v-for="(server, idx) in info.server_info"
|
||||
:key="idx"
|
||||
>
|
||||
<td
|
||||
class="px-6 py-4 whitespace-nowrap text-sm text-slate-300 font-mono"
|
||||
>
|
||||
{{ server.server }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
:class="server.connected ? 'bg-green-900 text-green-300' : 'bg-red-900 text-red-300'"
|
||||
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full"
|
||||
>
|
||||
{{ server.connected ? '已连接' :
|
||||
'未连接' }}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
class="px-6 py-4 whitespace-nowrap text-sm text-slate-400"
|
||||
>
|
||||
{{ server.server_rtt ? server.server_rtt
|
||||
+ ' ms' : '-' }}
|
||||
</td>
|
||||
<td
|
||||
class="px-6 py-4 whitespace-nowrap text-sm text-slate-400"
|
||||
>
|
||||
{{ server.server_version || '-' }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-if="!info.server_info || info.server_info.length === 0"
|
||||
>
|
||||
<td
|
||||
colspan="4"
|
||||
class="px-6 py-4 text-center text-slate-500"
|
||||
>
|
||||
暂无数据
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 2. Config (配置) -->
|
||||
<template id="tpl-config">
|
||||
<div class="space-y-6 max-w-5xl mx-auto h-full flex flex-col">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-2xl font-bold text-white">配置管理</h2>
|
||||
<button
|
||||
@click="openEditor(null)"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium flex items-center transition-colors"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
></path>
|
||||
</svg>
|
||||
新建配置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
|
||||
>
|
||||
<div
|
||||
v-for="cfg in configList"
|
||||
:key="cfg.file_name"
|
||||
:class="{'ring-2 ring-green-500 bg-slate-800/80': info.current_config_file === cfg.file_name, 'bg-slate-800/40 hover:bg-slate-800/60': info.current_config_file !== cfg.file_name}"
|
||||
class="rounded-xl p-5 border border-slate-700 transition-all cursor-pointer group relative overflow-hidden flex flex-col justify-between min-h-[140px]"
|
||||
@click="openEditor(cfg.file_name)"
|
||||
>
|
||||
<div
|
||||
v-if="info.current_config_file === cfg.file_name"
|
||||
class="absolute top-0 right-0 bg-green-500 text-white text-xs px-2 py-1 rounded-bl"
|
||||
>
|
||||
Running
|
||||
</div>
|
||||
<div class="flex items-start">
|
||||
<div
|
||||
class="p-2 rounded bg-blue-500/10 text-blue-400 mr-3 mt-1"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="overflow-hidden">
|
||||
<h3
|
||||
class="font-bold text-lg text-white truncate"
|
||||
:title="cfg.config_name"
|
||||
>
|
||||
{{ cfg.config_name || 'Unnamed' }}
|
||||
</h3>
|
||||
<p
|
||||
class="text-xs text-slate-500 font-mono truncate"
|
||||
:title="cfg.file_name"
|
||||
>
|
||||
{{ cfg.file_name }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-4 flex justify-end opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<button
|
||||
@click.stop="openEditor(cfg.file_name)"
|
||||
class="text-blue-400 hover:text-blue-300 text-sm mr-4"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
@click.stop="deleteConfig(cfg.file_name)"
|
||||
class="text-red-400 hover:text-red-300 text-sm"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑器 Modal -->
|
||||
<div
|
||||
v-if="showEditor"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
|
||||
>
|
||||
<div
|
||||
class="bg-slate-900 border border-slate-700 rounded-xl w-full max-w-6xl h-[85vh] flex flex-col shadow-2xl"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center p-4 border-b border-slate-700 bg-slate-800/50">
|
||||
<h3 class="text-lg font-bold text-white">
|
||||
{{ editorMode === 'new' ? '新建配置' : '编辑配置' }}
|
||||
</h3>
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- 模式切换按钮 -->
|
||||
<div class="flex bg-slate-800 rounded-lg p-1 border border-slate-600">
|
||||
<button
|
||||
@click="switchToFormMode"
|
||||
:class="editMode === 'form' ? 'bg-blue-600 text-white' : 'text-slate-400 hover:text-white'"
|
||||
class="px-4 py-1.5 rounded text-sm font-medium transition-colors flex items-center"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
表单模式
|
||||
</button>
|
||||
<button
|
||||
@click="switchToTomlMode"
|
||||
:class="editMode === 'toml' ? 'bg-blue-600 text-white' : 'text-slate-400 hover:text-white'"
|
||||
class="px-4 py-1.5 rounded text-sm font-medium transition-colors flex items-center"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"></path>
|
||||
</svg>
|
||||
TOML模式
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-sm text-slate-500 font-mono" v-if="editorFileName">
|
||||
{{ editorFileName }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<!-- 表单模式 -->
|
||||
<div v-show="editMode === 'form'" class="h-full overflow-y-auto scrollbar-hide p-6">
|
||||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
<!-- 基础配置 -->
|
||||
<div class="glass-panel rounded-lg p-5 border border-slate-700">
|
||||
<h4 class="text-md font-bold text-blue-400 mb-4 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||
</svg>
|
||||
基础配置
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">配置名称</label>
|
||||
<input v-model="formData.config_name" type="text" placeholder="例如: 我的VPN配置"
|
||||
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">
|
||||
网络编号 <span class="text-red-400">*</span>
|
||||
</label>
|
||||
<input v-model="formData.network_code" type="text" placeholder="例如: my_network" required
|
||||
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">
|
||||
服务器地址 <span class="text-red-400">*</span>
|
||||
<span class="text-xs text-slate-500 ml-2">支持 quic:// tcp:// wss:// dynamic://</span>
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(server, idx) in formData.server" :key="idx" class="flex space-x-2">
|
||||
<input v-model="formData.server[idx]" type="text" placeholder="例如: quic://1.2.3.4:29872"
|
||||
class="flex-1 bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<button @click="formData.server.splice(idx, 1)" v-if="formData.server.length > 1"
|
||||
class="px-3 py-2 bg-red-600/20 hover:bg-red-600/40 text-red-400 rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button @click="formData.server.push('')"
|
||||
class="w-full px-3 py-2 bg-blue-600/20 hover:bg-blue-600/40 text-blue-400 rounded-lg transition-colors text-sm flex items-center justify-center">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||
</svg>
|
||||
添加服务器
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 网络设置 -->
|
||||
<div class="glass-panel rounded-lg p-5 border border-slate-700">
|
||||
<h4 class="text-md font-bold text-green-400 mb-4 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path>
|
||||
</svg>
|
||||
网络设置
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">
|
||||
自定义虚拟IP
|
||||
<span class="text-xs text-slate-500 ml-1">(可选)</span>
|
||||
</label>
|
||||
<input v-model="formData.ip" type="text" placeholder="例如: 10.26.0.2"
|
||||
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">MTU</label>
|
||||
<input v-model.number="formData.mtu" type="number" placeholder="1380"
|
||||
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 传输优化 -->
|
||||
<div class="glass-panel rounded-lg p-5 border border-slate-700">
|
||||
<h4 class="text-md font-bold text-purple-400 mb-4 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||
</svg>
|
||||
传输优化
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<label class="flex items-center justify-between p-3 bg-slate-800/50 rounded-lg border border-slate-700 cursor-pointer hover:bg-slate-800 transition-colors">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-white">QUIC传输优化</div>
|
||||
<div class="text-xs text-slate-400 mt-0.5">重传丢包</div>
|
||||
</div>
|
||||
<input v-model="formData.rtx" type="checkbox" class="w-5 h-5 text-blue-600 bg-slate-700 border-slate-600 rounded focus:ring-blue-500">
|
||||
</label>
|
||||
<label class="flex items-center justify-between p-3 bg-slate-800/50 rounded-lg border border-slate-700 cursor-pointer hover:bg-slate-800 transition-colors">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-white">FEC前向纠错</div>
|
||||
<div class="text-xs text-slate-400 mt-0.5">损失部分带宽提升稳定性</div>
|
||||
</div>
|
||||
<input v-model="formData.fec" type="checkbox" class="w-5 h-5 text-blue-600 bg-slate-700 border-slate-600 rounded focus:ring-blue-500">
|
||||
</label>
|
||||
<label class="flex items-center justify-between p-3 bg-slate-800/50 rounded-lg border border-slate-700 cursor-pointer hover:bg-slate-800 transition-colors">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-white">LZ4压缩</div>
|
||||
<div class="text-xs text-slate-400 mt-0.5">减少传输数据量</div>
|
||||
</div>
|
||||
<input v-model="formData.compress" type="checkbox" class="w-5 h-5 text-blue-600 bg-slate-700 border-slate-600 rounded focus:ring-blue-500">
|
||||
</label>
|
||||
<label class="flex items-center justify-between p-3 bg-slate-800/50 rounded-lg border border-slate-700 cursor-pointer hover:bg-slate-800 transition-colors">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-white">关闭P2P打洞</div>
|
||||
<div class="text-xs text-slate-400 mt-0.5">仅通过服务器中转</div>
|
||||
</div>
|
||||
<input v-model="formData.no_punch" type="checkbox" class="w-5 h-5 text-blue-600 bg-slate-700 border-slate-600 rounded focus:ring-blue-500">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 安全配置 -->
|
||||
<div class="glass-panel rounded-lg p-5 border border-slate-700">
|
||||
<h4 class="text-md font-bold text-red-400 mb-4 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
||||
</svg>
|
||||
安全配置
|
||||
</h4>
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">组网加密密码(连接公共服务器时建议填写,同一组网密码需要相同)</label>
|
||||
<input v-model="formData.password" type="password" placeholder="留空则不加密"
|
||||
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">服务端证书校验模式</label>
|
||||
<select v-model="formData.cert_mode"
|
||||
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<option value="skip">跳过验证 (默认)</option>
|
||||
<option value="standard">系统证书验证</option>
|
||||
<option value="finger">证书指纹验证</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="formData.cert_mode === 'finger'" class="animate-in fade-in slide-in-from-top-2">
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">
|
||||
证书指纹
|
||||
<span class="text-xs text-slate-500 ml-1">(服务端启动时日志会输出指纹)</span>
|
||||
</label>
|
||||
<input v-model="formData.fingerprint" type="text" placeholder="例如: 3bdd8675606837cdf95d5e13445606315762315a78555f9da652940a25feaec1"
|
||||
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono text-sm">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NAT与路由 -->
|
||||
<div class="glass-panel rounded-lg p-5 border border-slate-700">
|
||||
<h4 class="text-md font-bold text-yellow-400 mb-4 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 9m0 0V7m0 2v6"></path>
|
||||
</svg>
|
||||
NAT与路由 (点对网)
|
||||
</h4>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">
|
||||
入栈网段
|
||||
<span class="text-xs text-slate-500 ml-1">格式: CIDR,目标IP</span>
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(item, idx) in formData.input" :key="idx" class="flex space-x-2">
|
||||
<input v-model="formData.input[idx]" type="text" placeholder="例如: 192.168.0.0/24,10.26.0.2"
|
||||
class="flex-1 bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm">
|
||||
<button @click="formData.input.splice(idx, 1)"
|
||||
class="px-3 py-2 bg-red-600/20 hover:bg-red-600/40 text-red-400 rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button @click="formData.input.push('')"
|
||||
class="w-full px-3 py-1.5 bg-yellow-600/20 hover:bg-yellow-600/40 text-yellow-400 rounded-lg transition-colors text-sm">
|
||||
+ 添加入栈网段
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">
|
||||
出栈网段
|
||||
<span class="text-xs text-slate-500 ml-1">格式: CIDR (允许转发的网段)</span>
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(item, idx) in formData.output" :key="idx" class="flex space-x-2">
|
||||
<input v-model="formData.output[idx]" type="text" placeholder="例如: 0.0.0.0/0"
|
||||
class="flex-1 bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm">
|
||||
<button @click="formData.output.splice(idx, 1)"
|
||||
class="px-3 py-2 bg-red-600/20 hover:bg-red-600/40 text-red-400 rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button @click="formData.output.push('')"
|
||||
class="w-full px-3 py-1.5 bg-yellow-600/20 hover:bg-yellow-600/40 text-yellow-400 rounded-lg transition-colors text-sm">
|
||||
+ 添加出栈网段
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<label class="flex items-center justify-between p-3 bg-slate-800/50 rounded-lg border border-slate-700 cursor-pointer hover:bg-slate-800 transition-colors">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-white">关闭内置NAT</div>
|
||||
<div class="text-xs text-slate-400 mt-0.5">使用系统网卡转发</div>
|
||||
</div>
|
||||
<input v-model="formData.no_nat" type="checkbox" class="w-5 h-5 text-blue-600 bg-slate-700 border-slate-600 rounded focus:ring-blue-500">
|
||||
</label>
|
||||
<label class="flex items-center justify-between p-3 bg-slate-800/50 rounded-lg border border-slate-700 cursor-pointer hover:bg-slate-800 transition-colors">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-white">关闭TUN网卡</div>
|
||||
<div class="text-xs text-slate-400 mt-0.5">仅作流量出口或端口映射</div>
|
||||
</div>
|
||||
<input v-model="formData.no_tun" type="checkbox" class="w-5 h-5 text-blue-600 bg-slate-700 border-slate-600 rounded focus:ring-blue-500">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 端口映射 -->
|
||||
<div class="glass-panel rounded-lg p-5 border border-slate-700">
|
||||
<h4 class="text-md font-bold text-orange-400 mb-4 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
端口映射
|
||||
</h4>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">
|
||||
映射规则
|
||||
<span class="text-xs text-slate-500 ml-1">格式: 协议://监听地址-虚拟IP-目标地址</span>
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(item, idx) in formData.port_mapping" :key="idx" class="flex space-x-2">
|
||||
<input v-model="formData.port_mapping[idx]" type="text" placeholder="例如: tcp://0.0.0.0:81-10.0.0.2-10.0.0.2:80"
|
||||
class="flex-1 bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm font-mono">
|
||||
<button @click="formData.port_mapping.splice(idx, 1)"
|
||||
class="px-3 py-2 bg-red-600/20 hover:bg-red-600/40 text-red-400 rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button @click="formData.port_mapping.push('')"
|
||||
class="w-full px-3 py-1.5 bg-orange-600/20 hover:bg-orange-600/40 text-orange-400 rounded-lg transition-colors text-sm">
|
||||
+ 添加映射规则
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="flex items-center justify-between p-3 bg-slate-800/50 rounded-lg border border-slate-700 cursor-pointer hover:bg-slate-800 transition-colors">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-white">允许作为映射出口</div>
|
||||
<div class="text-xs text-slate-400 mt-0.5">允许其他设备使用本机作跳板</div>
|
||||
</div>
|
||||
<input v-model="formData.allow_mapping" type="checkbox" class="w-5 h-5 text-blue-600 bg-slate-700 border-slate-600 rounded focus:ring-blue-500">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设备配置 -->
|
||||
<div class="glass-panel rounded-lg p-5 border border-slate-700">
|
||||
<h4 class="text-md font-bold text-cyan-400 mb-4 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
设备配置
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">设备名称</label>
|
||||
<input v-model="formData.device_name" type="text" placeholder="默认为主机名"
|
||||
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">设备ID</label>
|
||||
<input v-model="formData.device_id" type="text" placeholder="自动生成"
|
||||
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">虚拟网卡名</label>
|
||||
<input v-model="formData.tun_name" type="text" placeholder="默认为vnt-tun"
|
||||
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STUN配置 -->
|
||||
<div class="glass-panel rounded-lg p-5 border border-slate-700">
|
||||
<h4 class="text-md font-bold text-pink-400 mb-4 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
STUN配置 (高级)
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">UDP STUN服务器</label>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(item, idx) in formData.udp_stun" :key="idx" class="flex space-x-2">
|
||||
<input v-model="formData.udp_stun[idx]" type="text" placeholder="例如: stun.l.google.com:19302"
|
||||
class="flex-1 bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm">
|
||||
<button @click="formData.udp_stun.splice(idx, 1)"
|
||||
class="px-3 py-2 bg-red-600/20 hover:bg-red-600/40 text-red-400 rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button @click="formData.udp_stun.push('')"
|
||||
class="w-full px-3 py-1.5 bg-pink-600/20 hover:bg-pink-600/40 text-pink-400 rounded-lg transition-colors text-sm">
|
||||
+ 添加UDP STUN
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">TCP STUN服务器</label>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(item, idx) in formData.tcp_stun" :key="idx" class="flex space-x-2">
|
||||
<input v-model="formData.tcp_stun[idx]" type="text" placeholder="例如: stun.nextcloud.com:443"
|
||||
class="flex-1 bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm">
|
||||
<button @click="formData.tcp_stun.splice(idx, 1)"
|
||||
class="px-3 py-2 bg-red-600/20 hover:bg-red-600/40 text-red-400 rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button @click="formData.tcp_stun.push('')"
|
||||
class="w-full px-3 py-1.5 bg-pink-600/20 hover:bg-pink-600/40 text-pink-400 rounded-lg transition-colors text-sm">
|
||||
+ 添加TCP STUN
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TOML模式 -->
|
||||
<div v-show="editMode === 'toml'" class="h-full">
|
||||
<textarea
|
||||
v-model="editorContent"
|
||||
class="w-full h-full bg-[#1e1e1e] text-[#d4d4d4] font-mono p-4 resize-none focus:outline-none text-sm"
|
||||
spellcheck="false"
|
||||
placeholder="# 在此处输入 TOML 配置..."
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="p-4 border-t border-slate-700 flex justify-between items-center bg-slate-800/50">
|
||||
<div class="text-xs text-slate-500">
|
||||
<span v-if="editMode === 'form'">填写完成后保存即可生成配置文件</span>
|
||||
<span v-else>* 请使用标准 TOML 格式</span>
|
||||
</div>
|
||||
<div class="space-x-3">
|
||||
<button
|
||||
@click="showEditor = false"
|
||||
class="px-4 py-2 rounded text-slate-300 hover:text-white transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
@click="saveConfig"
|
||||
class="px-6 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded font-medium shadow-lg transition-colors"
|
||||
>
|
||||
保存配置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 3. Peers (设备列表) -->
|
||||
<template id="tpl-peers">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<div class="glass-panel rounded-xl shadow-lg overflow-hidden">
|
||||
<div
|
||||
class="px-6 py-4 border-b border-slate-700 flex justify-between items-center"
|
||||
>
|
||||
<h2 class="text-xl font-bold text-white">设备列表</h2>
|
||||
<div class="flex space-x-4 text-sm text-slate-400">
|
||||
<span
|
||||
>Online:
|
||||
<span class="text-white"
|
||||
>{{ peers.filter(p => p.online).length
|
||||
}}</span
|
||||
></span
|
||||
>
|
||||
<span
|
||||
>Total:
|
||||
<span class="text-white"
|
||||
>{{ peers.length }}</span
|
||||
></span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-auto max-h-[600px] custom-scrollbar">
|
||||
<table class="min-w-full divide-y divide-slate-700">
|
||||
<thead class="bg-slate-800">
|
||||
<tr>
|
||||
<th class="w-8 px-2 py-3"></th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-slate-400 tracking-wider"
|
||||
>
|
||||
IP地址
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-slate-400 tracking-wider"
|
||||
>
|
||||
名称
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-slate-400 tracking-wider"
|
||||
>
|
||||
版本
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-slate-400 tracking-wider"
|
||||
>
|
||||
状态
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-slate-400 tracking-wider"
|
||||
>
|
||||
模式
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-slate-400 tracking-wider"
|
||||
>
|
||||
延迟
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-slate-400 tracking-wider"
|
||||
>
|
||||
丢包率
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-slate-400 tracking-wider"
|
||||
>
|
||||
流量
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-slate-400 tracking-wider"
|
||||
>
|
||||
最后在线
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
class="divide-y divide-slate-700 bg-slate-900/30"
|
||||
>
|
||||
<template v-for="peer in peers" :key="peer.ip">
|
||||
<tr class="hover:bg-slate-800/50 transition-colors">
|
||||
<td class="px-2 py-4 text-center cursor-pointer select-none" @click="toggleExpand(peer.ip)">
|
||||
<svg class="w-4 h-4 text-slate-500 transition-transform duration-200 inline-block" :class="{ 'rotate-90': expandedPeers[peer.ip] }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
</td>
|
||||
<td
|
||||
class="px-6 py-4 whitespace-nowrap font-mono text-sm text-blue-300"
|
||||
>
|
||||
<span
|
||||
class="border-b border-dotted border-blue-500/50 pb-0.5 cursor-help"
|
||||
@mouseenter="showPeerTooltip($event, peer)"
|
||||
@mouseleave="hidePeerTooltip"
|
||||
>
|
||||
{{ peer.ip }}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
class="px-6 py-4 whitespace-nowrap text-sm text-slate-300"
|
||||
>
|
||||
{{ peer.name || '-' }}
|
||||
</td>
|
||||
<td
|
||||
class="px-6 py-4 whitespace-nowrap text-xs text-slate-500"
|
||||
>
|
||||
{{ peer.version || '-' }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span
|
||||
:class="peer.online ? 'bg-green-900 text-green-300' : 'bg-slate-700 text-slate-400'"
|
||||
class="px-2 py-0.5 text-xs rounded-full font-medium"
|
||||
>{{ peer.online ? 'Online' : 'Offline' }}</span>
|
||||
<!-- 加密状态图标 -->
|
||||
<div v-if="peer.online && peer.key_equal === 1" class="tooltip">
|
||||
<svg
|
||||
class="w-4 h-4 text-green-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
></path>
|
||||
</svg>
|
||||
<span class="tooltip-text">双方加密传输</span>
|
||||
</div>
|
||||
<div v-else-if="peer.online && peer.key_equal === 2" class="tooltip">
|
||||
<svg
|
||||
class="w-4 h-4 text-yellow-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z"
|
||||
></path>
|
||||
</svg>
|
||||
<span class="tooltip-text">双方未加密</span>
|
||||
</div>
|
||||
<div v-else-if="peer.online && [3,4,5].includes(peer.key_equal)" class="tooltip cursor-help group">
|
||||
<svg
|
||||
class="w-4 h-4 text-red-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<span class="tooltip-text">{{ peer.key_equal === 3 ? '己方加密对方未加密' : peer.key_equal === 4 ? '己方未加密对方加密' : peer.key_equal === 5 ? '密钥不一致' : '未知错误' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="px-6 py-4 whitespace-nowrap text-sm"
|
||||
>
|
||||
<span
|
||||
v-if="peer.online && peer.route"
|
||||
:class="getRouteModeClass(peer.route)"
|
||||
>{{ getRouteModeText(peer.route) }}</span
|
||||
>
|
||||
<span v-else-if="peer.online" class="text-yellow-400">服务器中继</span>
|
||||
<span v-else class="text-slate-600"
|
||||
>-</span
|
||||
>
|
||||
</td>
|
||||
<td
|
||||
class="px-6 py-4 whitespace-nowrap text-sm text-slate-400"
|
||||
>
|
||||
{{ peer.route ? peer.route.rtt + ' ms' : '-' }}
|
||||
</td>
|
||||
<td
|
||||
class="px-6 py-4 whitespace-nowrap text-sm"
|
||||
>
|
||||
<span v-if="peer.packet_loss"
|
||||
:class="peer.packet_loss.loss_rate > 10 ? 'text-red-400' : peer.packet_loss.loss_rate > 5 ? 'text-yellow-400' : 'text-green-400'"
|
||||
:title="'Sent: ' + peer.packet_loss.sent + ', Received: ' + peer.packet_loss.received"
|
||||
>{{ peer.packet_loss.loss_rate.toFixed(1) }}%</span>
|
||||
<span v-else class="text-slate-600">-</span>
|
||||
</td>
|
||||
<td
|
||||
class="px-6 py-4 whitespace-nowrap text-xs"
|
||||
>
|
||||
<div v-if="peer.traffic" class="leading-relaxed">
|
||||
<div class="text-green-400">↑ {{ formatBytes(peer.traffic.tx_bytes) }} ({{ formatSpeed(peer.traffic.tx_speed) }})</div>
|
||||
<div class="text-blue-400">↓ {{ formatBytes(peer.traffic.rx_bytes) }} ({{ formatSpeed(peer.traffic.rx_speed) }})</div>
|
||||
</div>
|
||||
<span v-else class="text-slate-600">-</span>
|
||||
</td>
|
||||
<td
|
||||
class="px-6 py-4 whitespace-nowrap text-sm text-slate-500 font-mono text-xs"
|
||||
>
|
||||
{{ formatTime(peer.last_connected_time)
|
||||
}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="expandedPeers[peer.ip]">
|
||||
<td :colspan="10" class="p-0">
|
||||
<div class="px-4 py-3 bg-slate-950/60 border-t border-slate-700/50">
|
||||
<div class="flex items-center space-x-4 mb-2 text-xs text-slate-400">
|
||||
<span class="flex items-center"><span class="inline-block w-3 h-0.5 bg-green-400 mr-1"></span>上传速度</span>
|
||||
<span class="flex items-center"><span class="inline-block w-3 h-0.5 bg-blue-400 mr-1"></span>下载速度</span>
|
||||
<span class="ml-auto" :id="'chart-max-' + peer.ip.replaceAll('.', '-')"></span>
|
||||
</div>
|
||||
<canvas :id="'chart-' + peer.ip.replaceAll('.', '-')" style="width:100%;height:150px;display:block;" class="rounded"></canvas>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 4. Routes (路由) -->
|
||||
<template id="tpl-routes">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="glass-panel rounded-xl shadow-lg overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-slate-700">
|
||||
<h2 class="text-xl font-bold text-white">路由表</h2>
|
||||
</div>
|
||||
<div class="overflow-auto max-h-[600px] custom-scrollbar">
|
||||
<table class="min-w-full divide-y divide-slate-700">
|
||||
<thead class="bg-slate-800">
|
||||
<tr>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider"
|
||||
>
|
||||
目标节点IP
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider"
|
||||
>
|
||||
目标网络
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider"
|
||||
>
|
||||
跳数
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider"
|
||||
>
|
||||
延迟
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
class="divide-y divide-slate-700 bg-slate-900/30"
|
||||
>
|
||||
<template v-for="item in routes" :key="item.ip">
|
||||
<tr
|
||||
v-for="(route, rIdx) in item.routes"
|
||||
:key="rIdx"
|
||||
class="hover:bg-slate-800/50"
|
||||
>
|
||||
<td
|
||||
class="px-6 py-4 whitespace-nowrap font-mono text-sm text-blue-300"
|
||||
v-if="rIdx===0"
|
||||
:rowspan="item.routes.length"
|
||||
>
|
||||
{{ item.ip }}
|
||||
</td>
|
||||
<td
|
||||
class="px-6 py-4 whitespace-nowrap font-mono text-sm text-yellow-300"
|
||||
>
|
||||
{{ route.addr }}
|
||||
</td>
|
||||
<td
|
||||
class="px-6 py-4 whitespace-nowrap text-sm text-slate-400"
|
||||
>
|
||||
{{ route.metric }}
|
||||
</td>
|
||||
<td
|
||||
class="px-6 py-4 whitespace-nowrap text-sm text-slate-400"
|
||||
>
|
||||
{{ route.rtt }} ms
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const {
|
||||
createApp,
|
||||
ref,
|
||||
reactive,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
computed,
|
||||
watch,
|
||||
nextTick,
|
||||
inject,
|
||||
provide,
|
||||
} = Vue;
|
||||
const {createRouter, createWebHashHistory} = VueRouter;
|
||||
|
||||
const API_BASE = "";
|
||||
|
||||
// 格式化时间工具
|
||||
const formatTime = (timestamp) => {
|
||||
if (!timestamp) return "-";
|
||||
const date = new Date(timestamp * 1000);
|
||||
const pad = (n) => (n < 10 ? "0" + n : n);
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
};
|
||||
|
||||
// 格式化字节数
|
||||
const formatBytes = (bytes) => {
|
||||
if (bytes === 0 || bytes === undefined || bytes === null) return "0B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let i = 0;
|
||||
let value = bytes;
|
||||
while (value >= 1024 && i < units.length - 1) {
|
||||
value /= 1024;
|
||||
i++;
|
||||
}
|
||||
return i === 0 ? value + units[i] : value.toFixed(2) + units[i];
|
||||
};
|
||||
|
||||
// 格式化速度(字节/秒)
|
||||
const formatSpeed = (bytesPerSecond) => {
|
||||
if (bytesPerSecond === 0 || bytesPerSecond === undefined || bytesPerSecond === null) return "0B/s";
|
||||
const units = ["B/s", "KB/s", "MB/s", "GB/s"];
|
||||
let i = 0;
|
||||
let value = bytesPerSecond;
|
||||
while (value >= 1024 && i < units.length - 1) {
|
||||
value /= 1024;
|
||||
i++;
|
||||
}
|
||||
return i === 0 ? value + units[i] : value.toFixed(2) + units[i];
|
||||
};
|
||||
|
||||
// --- 组件定义 ---
|
||||
|
||||
const GeneralView = {
|
||||
template: "#tpl-general",
|
||||
setup() {
|
||||
const info = inject("info");
|
||||
const configList = inject("configList");
|
||||
const toggleVnt = inject("toggleVnt");
|
||||
const restartVnt = inject("restartVnt");
|
||||
const loading = inject("loading");
|
||||
const localSelectedConfig = ref("");
|
||||
|
||||
// 同步当前配置
|
||||
watch(
|
||||
() => info.value.current_config_file,
|
||||
(newVal) => {
|
||||
if (
|
||||
(info.value.status === "running" ||
|
||||
info.value.status === "starting") &&
|
||||
newVal
|
||||
) {
|
||||
localSelectedConfig.value = newVal;
|
||||
}
|
||||
},
|
||||
{immediate: true},
|
||||
);
|
||||
|
||||
const handleToggle = () => {
|
||||
// 如果是启动,且有本地选择的配置,传递给 toggle
|
||||
if (
|
||||
info.value.status !== "running" &&
|
||||
info.value.status !== "starting"
|
||||
) {
|
||||
toggleVnt(localSelectedConfig.value);
|
||||
} else {
|
||||
toggleVnt(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestart = () => {
|
||||
if (localSelectedConfig.value) {
|
||||
restartVnt(localSelectedConfig.value);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
info,
|
||||
configList,
|
||||
localSelectedConfig,
|
||||
loading,
|
||||
handleToggle,
|
||||
handleRestart,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const ConfigView = {
|
||||
template: "#tpl-config",
|
||||
setup() {
|
||||
const info = inject("info");
|
||||
const configList = inject("configList");
|
||||
const fetchConfigList = inject("fetchConfigList");
|
||||
|
||||
// 编辑器状态
|
||||
const showEditor = ref(false);
|
||||
const editorContent = ref("");
|
||||
const editorFileName = ref("");
|
||||
const editorMode = ref("new");
|
||||
const editMode = ref("form"); // 'form' 或 'toml'
|
||||
const originalToml = ref(""); // 保存原始TOML内容(包含用户注释)
|
||||
const hasTomlChanges = ref(false); // 标记TOML是否被修改过
|
||||
const hasFormChanges = ref(false); // 标记表单是否被修改过
|
||||
const isParsingToml = ref(false); // 标记是否正在解析TOML(用于避免触发watch)
|
||||
|
||||
// 表单数据
|
||||
const formData = ref({
|
||||
config_name: "",
|
||||
network_code: "",
|
||||
server: [""],
|
||||
ip: "",
|
||||
mtu: null,
|
||||
rtx: false,
|
||||
fec: false,
|
||||
compress: false,
|
||||
no_punch: false,
|
||||
input: [],
|
||||
output: [],
|
||||
no_nat: false,
|
||||
no_tun: false,
|
||||
port_mapping: [],
|
||||
allow_mapping: false,
|
||||
device_name: "",
|
||||
device_id: "",
|
||||
tun_name: "",
|
||||
password: "",
|
||||
cert_mode: "skip",
|
||||
fingerprint: "",
|
||||
udp_stun: [],
|
||||
tcp_stun: []
|
||||
});
|
||||
|
||||
// 从TOML解析到表单
|
||||
const parseTomlToForm = (toml) => {
|
||||
const data = {
|
||||
config_name: "",
|
||||
network_code: "",
|
||||
server: [""],
|
||||
ip: "",
|
||||
mtu: null,
|
||||
rtx: false,
|
||||
fec: false,
|
||||
compress: false,
|
||||
no_punch: false,
|
||||
input: [],
|
||||
output: [],
|
||||
no_nat: false,
|
||||
no_tun: false,
|
||||
port_mapping: [],
|
||||
allow_mapping: false,
|
||||
device_name: "",
|
||||
device_id: "",
|
||||
tun_name: "",
|
||||
password: "",
|
||||
cert_mode: "skip",
|
||||
fingerprint: "",
|
||||
udp_stun: [],
|
||||
tcp_stun: []
|
||||
};
|
||||
|
||||
const lines = toml.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
|
||||
if (trimmed.includes('config_name')) {
|
||||
const match = trimmed.match(/config_name\s*=\s*"([^"]*)"/);
|
||||
if (match) data.config_name = match[1];
|
||||
} else if (trimmed.includes('network_code')) {
|
||||
const match = trimmed.match(/network_code\s*=\s*"([^"]*)"/);
|
||||
if (match) data.network_code = match[1];
|
||||
} else if (trimmed.startsWith('server')) {
|
||||
const match = trimmed.match(/server\s*=\s*\[(.*)\]/);
|
||||
if (match) {
|
||||
const items = match[1].match(/"([^"]*)"/g);
|
||||
if (items) data.server = items.map(s => s.replace(/"/g, ''));
|
||||
}
|
||||
} else if (trimmed.includes('ip =')) {
|
||||
const match = trimmed.match(/ip\s*=\s*"([^"]*)"/);
|
||||
if (match) data.ip = match[1];
|
||||
} else if (trimmed.includes('mtu =')) {
|
||||
const match = trimmed.match(/mtu\s*=\s*(\d+)/);
|
||||
if (match) data.mtu = parseInt(match[1]);
|
||||
} else if (trimmed.match(/^rtx\s*=/)) {
|
||||
data.rtx = trimmed.includes('true');
|
||||
} else if (trimmed.match(/^fec\s*=/)) {
|
||||
data.fec = trimmed.includes('true');
|
||||
} else if (trimmed.match(/^compress\s*=/)) {
|
||||
data.compress = trimmed.includes('true');
|
||||
} else if (trimmed.match(/^no_punch\s*=/)) {
|
||||
data.no_punch = trimmed.includes('true');
|
||||
} else if (trimmed.startsWith('input')) {
|
||||
const match = trimmed.match(/input\s*=\s*\[(.*)\]/);
|
||||
if (match) {
|
||||
const items = match[1].match(/"([^"]*)"/g);
|
||||
if (items) data.input = items.map(s => s.replace(/"/g, ''));
|
||||
}
|
||||
} else if (trimmed.startsWith('output')) {
|
||||
const match = trimmed.match(/output\s*=\s*\[(.*)\]/);
|
||||
if (match) {
|
||||
const items = match[1].match(/"([^"]*)"/g);
|
||||
if (items) data.output = items.map(s => s.replace(/"/g, ''));
|
||||
}
|
||||
} else if (trimmed.match(/^no_nat\s*=/)) {
|
||||
data.no_nat = trimmed.includes('true');
|
||||
} else if (trimmed.match(/^no_tun\s*=/)) {
|
||||
data.no_tun = trimmed.includes('true');
|
||||
} else if (trimmed.startsWith('port_mapping')) {
|
||||
const match = trimmed.match(/port_mapping\s*=\s*\[(.*)\]/);
|
||||
if (match) {
|
||||
const items = match[1].match(/"([^"]*)"/g);
|
||||
if (items) data.port_mapping = items.map(s => s.replace(/"/g, ''));
|
||||
}
|
||||
} else if (trimmed.match(/^allow_mapping\s*=/)) {
|
||||
data.allow_mapping = trimmed.includes('true');
|
||||
} else if (trimmed.includes('device_name')) {
|
||||
const match = trimmed.match(/device_name\s*=\s*"([^"]*)"/);
|
||||
if (match) data.device_name = match[1];
|
||||
} else if (trimmed.includes('device_id')) {
|
||||
const match = trimmed.match(/device_id\s*=\s*"([^"]*)"/);
|
||||
if (match) data.device_id = match[1];
|
||||
} else if (trimmed.includes('tun_name')) {
|
||||
const match = trimmed.match(/tun_name\s*=\s*"([^"]*)"/);
|
||||
if (match) data.tun_name = match[1];
|
||||
} else if (trimmed.includes('password =')) {
|
||||
const match = trimmed.match(/password\s*=\s*"([^"]*)"/);
|
||||
if (match) data.password = match[1];
|
||||
} else if (trimmed.includes('cert_mode')) {
|
||||
const match = trimmed.match(/cert_mode\s*=\s*"([^"]*)"/);
|
||||
if (match) {
|
||||
const value = match[1];
|
||||
if (value.startsWith('finger:')) {
|
||||
data.cert_mode = 'finger';
|
||||
data.fingerprint = value.substring(7); // 去掉 "finger:" 前缀
|
||||
} else {
|
||||
data.cert_mode = value;
|
||||
}
|
||||
}
|
||||
} else if (trimmed.startsWith('udp_stun')) {
|
||||
const match = trimmed.match(/udp_stun\s*=\s*\[(.*)\]/);
|
||||
if (match) {
|
||||
const items = match[1].match(/"([^"]*)"/g);
|
||||
if (items) data.udp_stun = items.map(s => s.replace(/"/g, ''));
|
||||
}
|
||||
} else if (trimmed.startsWith('tcp_stun')) {
|
||||
const match = trimmed.match(/tcp_stun\s*=\s*\[(.*)\]/);
|
||||
if (match) {
|
||||
const items = match[1].match(/"([^"]*)"/g);
|
||||
if (items) data.tcp_stun = items.map(s => s.replace(/"/g, ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
// 从表单生成TOML
|
||||
const formToToml = () => {
|
||||
let toml = '';
|
||||
|
||||
if (formData.value.config_name) {
|
||||
toml += `# 配置名称\nconfig_name = "${formData.value.config_name}"\n`;
|
||||
}
|
||||
|
||||
toml += '\n# --- 网络配置 ---\n';
|
||||
toml += '# 网络编号,相同网络编号的会组在同一个虚拟网 (必填)\n';
|
||||
toml += `network_code = "${formData.value.network_code}"\n\n`;
|
||||
|
||||
const servers = formData.value.server.filter(s => s.trim());
|
||||
if (servers.length > 0) {
|
||||
toml += '# 服务器地址列表(支持 quic / tcp / wss / dynamic) (必填)\n';
|
||||
toml += '# dynamic 协议使用dns txt解析记录值\n';
|
||||
toml += `server = [${servers.map(s => `"${s}"`).join(', ')}]\n`;
|
||||
}
|
||||
|
||||
if (formData.value.ip) {
|
||||
toml += '\n# 自定义虚拟 IP (可选)\n';
|
||||
toml += `ip = "${formData.value.ip}"\n`;
|
||||
}
|
||||
|
||||
if (formData.value.rtx) {
|
||||
toml += '\n# 是否启用quic优化传输 (默认 false)\n';
|
||||
toml += '# 开启后传输过程几乎不会丢包,但是延迟可能会有波动\n';
|
||||
toml += 'rtx = true\n';
|
||||
}
|
||||
|
||||
if (formData.value.fec) {
|
||||
toml += '\n# 是否启用 FEC 前向纠错 (默认 false)\n';
|
||||
toml += '# 开启后可以减少丢包率,损失带宽但是延迟比较稳定,带宽充足时可以使用此功能\n';
|
||||
toml += 'fec = true\n';
|
||||
}
|
||||
|
||||
if (formData.value.no_punch) {
|
||||
toml += '\n# 是否关闭 P2P 打洞 (默认 false)\n';
|
||||
toml += 'no_punch = true\n';
|
||||
}
|
||||
|
||||
if (formData.value.compress) {
|
||||
toml += '\n# 是否启用 LZ4 压缩 (默认 false)\n';
|
||||
toml += 'compress = true\n';
|
||||
}
|
||||
|
||||
const inputs = formData.value.input.filter(s => s.trim());
|
||||
if (inputs.length > 0) {
|
||||
toml += '\n# 入栈监听网段 (逗号分隔的 CIDR 和目标 IP),用于点对网,将指定网段的流量发送到目标节点\n';
|
||||
toml += '# 例如192.168.0.0/24,10.26.0.2 表示将192.168.0.0/24网段的数据转发到10.26.0.2\n';
|
||||
toml += `input = [${inputs.map(s => `"${s}"`).join(', ')}]\n`;
|
||||
}
|
||||
|
||||
const outputs = formData.value.output.filter(s => s.trim());
|
||||
if (outputs.length > 0) {
|
||||
toml += '\n# 出栈允许网段,用于点对网,允许指定网段的转发\n';
|
||||
toml += `output = [${outputs.map(s => `"${s}"`).join(', ')}]\n`;
|
||||
}
|
||||
|
||||
if (formData.value.no_nat) {
|
||||
toml += '\n# 是否关闭内置子网NAT,关闭后需要配置网卡转发,否则无法使用点对网\n';
|
||||
toml += '# 通常关闭内置子网NAT,使用系统的网卡转发,点对网性能会更好\n';
|
||||
toml += 'no_nat = true\n';
|
||||
}
|
||||
|
||||
if (formData.value.no_tun) {
|
||||
toml += '\n# 是否关闭TUN虚拟网卡,关闭后只能充当流量出口或者进行端口映射,关闭后无需管理员权限\n';
|
||||
toml += 'no_tun = true\n';
|
||||
}
|
||||
|
||||
const portMappings = formData.value.port_mapping.filter(s => s.trim());
|
||||
if (portMappings.length > 0) {
|
||||
toml += '\n# 端口映射,格式为:协议://本地监听地址-目标虚拟IP-目标映射地址\n';
|
||||
toml += '# 端口映射用于在本地监听指定端口,并将收到的网络流量经由指定虚拟节点转发到目标地址\n';
|
||||
toml += '# 例如: tcp://0.0.0.0:81-10.0.0.2-10.0.0.2:80 表示将本地tcp的81端口的数据转发到10.0.0.2:80\n';
|
||||
toml += '# 例如: tcp://0.0.0.0:81-10.0.0.2-192.168.1.10:80 则表示将本地tcp的81端口的数据经过10.0.0.2转到192.168.1.10:80\n';
|
||||
toml += '# 例如: tcp://0.0.0.0:81-10.0.0.2-anyonehost:80 则表示将本地tcp的81端口的数据经过10.0.0.2转到anyonehost:80\n';
|
||||
toml += `port_mapping = [${portMappings.map(s => `"${s}"`).join(', ')}]\n`;
|
||||
}
|
||||
|
||||
if (formData.value.allow_mapping) {
|
||||
toml += '\n# 是否允许作为端口映射出口,开启后其他设备才可使用本设备的ip为"目标虚拟IP"\n';
|
||||
toml += '# 开启后虚拟网络其他设备可以使用此设备当跳板访问其他网络\n';
|
||||
toml += 'allow_mapping = true\n';
|
||||
}
|
||||
|
||||
if (formData.value.mtu) {
|
||||
toml += '\n# MTU 设置\n';
|
||||
toml += `mtu = ${formData.value.mtu}\n`;
|
||||
}
|
||||
|
||||
toml += '\n# --- 设备配置 ---\n';
|
||||
if (formData.value.device_name) {
|
||||
toml += '\n# 设备名称 (可选,默认读取本机 hostname)\n';
|
||||
toml += `device_name = "${formData.value.device_name}"\n`;
|
||||
}
|
||||
if (formData.value.device_id) {
|
||||
toml += '\n# 设备 ID (可选,不填自动生成,不同设备ID不能相同)\n';
|
||||
toml += `device_id = "${formData.value.device_id}"\n`;
|
||||
}
|
||||
if (formData.value.tun_name) {
|
||||
toml += '\n# 虚拟网卡名称\n';
|
||||
toml += `tun_name = "${formData.value.tun_name}"\n`;
|
||||
}
|
||||
|
||||
toml += '\n# --- 安全配置 ---\n';
|
||||
if (formData.value.password) {
|
||||
toml += '\n# 组网加密密码 (可选)\n';
|
||||
toml += `password = "${formData.value.password}"\n`;
|
||||
}
|
||||
if (formData.value.cert_mode && formData.value.cert_mode !== 'skip') {
|
||||
toml += '\n# 证书校验方式:\n';
|
||||
toml += '# skip 跳过验证(默认)\n';
|
||||
toml += '# standard 使用系统证书验证\n';
|
||||
toml += '# finger 使用证书指纹验证,服务端启动时日志会输出指纹\n';
|
||||
if (formData.value.cert_mode === 'finger' && formData.value.fingerprint) {
|
||||
toml += `cert_mode = "finger:${formData.value.fingerprint}"\n`;
|
||||
} else {
|
||||
toml += `cert_mode = "${formData.value.cert_mode}"\n`;
|
||||
}
|
||||
}
|
||||
|
||||
const udpStuns = formData.value.udp_stun.filter(s => s.trim());
|
||||
if (udpStuns.length > 0) {
|
||||
toml += '\n# 自定义UDP STUN地址,不设置则用默认stun\n';
|
||||
toml += `udp_stun = [${udpStuns.map(s => `"${s}"`).join(', ')}]\n`;
|
||||
}
|
||||
|
||||
const tcpStuns = formData.value.tcp_stun.filter(s => s.trim());
|
||||
if (tcpStuns.length > 0) {
|
||||
toml += '\n# 自定义TCP STUN地址,不设置则用默认stun\n';
|
||||
toml += `tcp_stun = [${tcpStuns.map(s => `"${s}"`).join(', ')}]\n`;
|
||||
}
|
||||
|
||||
return toml;
|
||||
};
|
||||
|
||||
// 切换到表单模式
|
||||
const switchToFormMode = () => {
|
||||
if (editMode.value === 'toml') {
|
||||
editMode.value = 'form';
|
||||
// 从TOML解析到表单
|
||||
isParsingToml.value = true;
|
||||
formData.value = parseTomlToForm(editorContent.value);
|
||||
nextTick(() => {
|
||||
isParsingToml.value = false;
|
||||
});
|
||||
} else {
|
||||
editMode.value = 'form';
|
||||
}
|
||||
};
|
||||
|
||||
// 切换到TOML模式
|
||||
const switchToTomlMode = () => {
|
||||
if (editMode.value === 'form') {
|
||||
// 如果表单被修改过,生成新的TOML
|
||||
if (hasFormChanges.value) {
|
||||
editorContent.value = formToToml();
|
||||
// 重置标记,因为表单修改已经应用到TOML了
|
||||
hasFormChanges.value = false;
|
||||
} else if (originalToml.value && !hasTomlChanges.value) {
|
||||
// 如果表单没被修改,且TOML也没被修改,使用原始TOML(保留用户注释)
|
||||
editorContent.value = originalToml.value;
|
||||
} else {
|
||||
// 其他情况生成新的TOML
|
||||
editorContent.value = formToToml();
|
||||
}
|
||||
}
|
||||
editMode.value = 'toml';
|
||||
};
|
||||
|
||||
// 监听TOML内容变化(只在TOML模式下)
|
||||
watch(editorContent, (newVal, oldVal) => {
|
||||
if (editMode.value === 'toml' && oldVal !== undefined) {
|
||||
hasTomlChanges.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
// 监听表单数据变化
|
||||
watch(formData, () => {
|
||||
if (editMode.value === 'form' && showEditor.value && !isParsingToml.value) {
|
||||
hasFormChanges.value = true;
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
const openEditor = async (fileName) => {
|
||||
editorFileName.value = fileName || "";
|
||||
editorMode.value = fileName ? "edit" : "new";
|
||||
editMode.value = "form"; // 默认表单模式
|
||||
hasTomlChanges.value = false; // 重置TOML修改标记
|
||||
hasFormChanges.value = false; // 重置表单修改标记
|
||||
|
||||
if (fileName) {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/config?file_name=${fileName}`,
|
||||
);
|
||||
const json = await res.json();
|
||||
if (json.code === 0) {
|
||||
editorContent.value = json.data;
|
||||
originalToml.value = json.data; // 保存原始TOML
|
||||
isParsingToml.value = true;
|
||||
formData.value = parseTomlToForm(json.data);
|
||||
nextTick(() => {
|
||||
isParsingToml.value = false;
|
||||
});
|
||||
showEditor.value = true;
|
||||
} else alert("获取配置失败: " + json.msg);
|
||||
} catch (e) {
|
||||
alert("网络错误");
|
||||
}
|
||||
} else {
|
||||
// 新建配置,初始化表单
|
||||
originalToml.value = ""; // 新建时清空原始TOML
|
||||
formData.value = {
|
||||
config_name: "",
|
||||
network_code: "",
|
||||
server: [""],
|
||||
ip: "",
|
||||
mtu: null,
|
||||
rtx: false,
|
||||
fec: false,
|
||||
compress: false,
|
||||
no_punch: false,
|
||||
input: [],
|
||||
output: [],
|
||||
no_nat: false,
|
||||
no_tun: false,
|
||||
port_mapping: [],
|
||||
allow_mapping: false,
|
||||
device_name: "",
|
||||
device_id: "",
|
||||
tun_name: "",
|
||||
password: "",
|
||||
cert_mode: "skip",
|
||||
fingerprint: "",
|
||||
udp_stun: [],
|
||||
tcp_stun: []
|
||||
};
|
||||
editorContent.value = `# config_name = "配置名称"
|
||||
# --- 网络配置 ---
|
||||
# 网络编号,相同网络编号的会组在同一个虚拟网 (必填)
|
||||
network_code = "your_network_code"
|
||||
|
||||
# 服务器地址列表(支持 quic / tcp / wss / dynamic) (必填)
|
||||
# dynamic 协议使用dns txt解析记录值
|
||||
server = ["quic://1.2.3.4:29872"]
|
||||
|
||||
# ===简单使用以下参数可以不动===
|
||||
|
||||
# 自定义虚拟 IP (可选)
|
||||
# ip = "10.10.0.2"
|
||||
|
||||
# 是否启用quic优化传输 (默认 false,设置为true时开启)
|
||||
# 开启后传输过程几乎不会丢包,但是延迟会有波动
|
||||
# rtx = false
|
||||
|
||||
# 是否启用 FEC 前向纠错,损失一定带宽来提升网络稳定性(默认 false,设置为true时开启)
|
||||
# 开启后可以减少丢包率,损失带宽但是延迟比较稳定,带宽充足时可以使用此功能
|
||||
# fec = false
|
||||
|
||||
# 是否关闭 P2P 打洞 (默认 false,设置为true时关闭)
|
||||
# no_punch = false
|
||||
|
||||
# 是否启用 LZ4 压缩 (默认 false,设置为true时开启)
|
||||
# compress = false
|
||||
|
||||
# 入栈监听网段 (逗号分隔的 CIDR 和目标 IP),用于点对网,将指定网段的流量发送到目标节点
|
||||
# input = ["192.168.0.0/24,10.26.0.2", "192.168.1.0/24,10.26.0.3"]
|
||||
|
||||
# 出栈允许网段,用于点对网,允许指定网段的转发
|
||||
# output = ["0.0.0.0/0"]
|
||||
|
||||
# 是否关闭内置子网NAT,关闭(设为true)后需要配置网卡转发,否则无法使用点对网。通常关闭内置子网NAT,使用系统的网卡转发,点对网性能会更好
|
||||
# no_nat = false
|
||||
|
||||
# 是否关闭TUN虚拟网卡,关闭(设为true)后只能充当流量出口或者进行端口映射,关闭后无需管理员权限
|
||||
# no_tun = false
|
||||
|
||||
# 端口映射,格式为:协议://本地监听地址-目标虚拟IP-目标映射地址
|
||||
# 端口映射用于在本地监听指定端口,并将收到的网络流量经由指定虚拟节点转发到目标地址,从而实现跨网络或内网服务访问
|
||||
# 例如 port_mapping = ["tcp://0.0.0.0:81-10.0.0.2-10.0.0.2:80"]
|
||||
# tcp://0.0.0.0:81-10.0.0.2-10.0.0.2:80 则表示将本地tcp的81端口的数据转发到10.0.0.2:80
|
||||
# tcp://0.0.0.0:81-10.0.0.2-192.168.1.10:80 则表示将本地tcp的81端口的数据经过10.0.0.2转到192.168.1.10:80
|
||||
# tcp://0.0.0.0:81-10.0.0.2-anyonehost:80 则表示将本地tcp的81端口的数据经过10.0.0.2转到anyonehost:80
|
||||
# port_mapping = []
|
||||
|
||||
# 是否允许作为端口映射出口,开启(设置为true)后其他设备才可使用本设备的ip为"目标虚拟IP"
|
||||
# 开启后虚拟网络其他设备可以使用此设备当跳板访问其他网络
|
||||
# allow_mapping = false
|
||||
|
||||
# MTU 设置
|
||||
# mtu = 1400
|
||||
|
||||
# --- 设备配置 ---
|
||||
|
||||
# 设备名称 (可选,默认读取本机 hostname)
|
||||
# device_name = "my-device"
|
||||
|
||||
# 设备 ID (可选,不填自动生成,不同设备ID不能相同)
|
||||
# device_id = "device-id-xxxx"
|
||||
|
||||
# 虚拟网卡名称
|
||||
# tun_name = "vnt-tun"
|
||||
|
||||
# --- 安全配置 ---
|
||||
|
||||
# 加密密码 (可选)
|
||||
# password = "123456"
|
||||
|
||||
# 证书校验方式:
|
||||
# skip 跳过验证(默认)
|
||||
# standard 使用系统证书验证
|
||||
# finger 使用证书指纹验证,服务端启动时日志会输出指纹,
|
||||
# 例如 finger:3bdd8675606837cdf95d5e13445606315762315a78555f9da652940a25feaec1
|
||||
# cert_mode = "skip"
|
||||
|
||||
# --- 其他配置 ---
|
||||
# 自定义stun地址,分别用于udp打洞和tcp打洞,需要单独配置,不设置则用默认stun
|
||||
# udp_stun = ["stun.chat.bilibili.com"]
|
||||
# tcp_stun = ["stun.nextcloud.com:443"]`;
|
||||
showEditor.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const saveConfig = async () => {
|
||||
try {
|
||||
// 如果是表单模式,先转换为TOML
|
||||
let configContent = editorContent.value;
|
||||
if (editMode.value === 'form') {
|
||||
// 验证必填项
|
||||
if (!formData.value.network_code.trim()) {
|
||||
alert('请填写网络编号');
|
||||
return;
|
||||
}
|
||||
const servers = formData.value.server.filter(s => s.trim());
|
||||
if (servers.length === 0) {
|
||||
alert('请至少填写一个服务器地址');
|
||||
return;
|
||||
}
|
||||
configContent = formToToml();
|
||||
}
|
||||
|
||||
const payload = {
|
||||
file_name: editorFileName.value || null,
|
||||
config: configContent,
|
||||
};
|
||||
const res = await fetch(`${API_BASE}/api/config`, {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.code === 0) {
|
||||
showEditor.value = false;
|
||||
fetchConfigList();
|
||||
} else alert("保存失败: " + json.msg);
|
||||
} catch (e) {
|
||||
alert("保存失败: " + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteConfig = async (fileName) => {
|
||||
if (!confirm(`确定要删除配置 ${fileName} 吗?`)) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/config?file_name=${fileName}`,
|
||||
{method: "DELETE"},
|
||||
);
|
||||
const json = await res.json();
|
||||
if (json.code === 0) fetchConfigList();
|
||||
else alert(json.msg);
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
info,
|
||||
configList,
|
||||
showEditor,
|
||||
editorContent,
|
||||
editorFileName,
|
||||
editorMode,
|
||||
editMode,
|
||||
formData,
|
||||
openEditor,
|
||||
saveConfig,
|
||||
deleteConfig,
|
||||
switchToFormMode,
|
||||
switchToTomlMode,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const PeersView = {
|
||||
template: "#tpl-peers",
|
||||
setup() {
|
||||
const peers = ref([]);
|
||||
const info = inject("info");
|
||||
const isPageVisible = inject("isPageVisible");
|
||||
const showTooltipGlobal = inject("showPeerTooltip");
|
||||
const hideTooltipGlobal = inject("hidePeerTooltip");
|
||||
let timer = null;
|
||||
// 记录上次流量数据和时间,用于前端计算网速
|
||||
let lastTrafficMap = {};
|
||||
let lastFetchTime = 0;
|
||||
// 展开状态和网速历史
|
||||
const expandedPeers = reactive({});
|
||||
const speedHistoryMap = {};
|
||||
const HISTORY_SIZE = 60;
|
||||
|
||||
const toggleExpand = (ip) => {
|
||||
expandedPeers[ip] = !expandedPeers[ip];
|
||||
if (expandedPeers[ip]) {
|
||||
nextTick(() => drawChart(ip));
|
||||
}
|
||||
};
|
||||
|
||||
const drawChart = (ip) => {
|
||||
const canvasId = 'chart-' + ip.replaceAll('.', '-');
|
||||
const canvas = document.getElementById(canvasId);
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const history = speedHistoryMap[ip];
|
||||
const txArr = history ? history.tx : [];
|
||||
const rxArr = history ? history.rx : [];
|
||||
|
||||
// 高清适配
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
const w = rect.width;
|
||||
const h = rect.height;
|
||||
|
||||
const padTop = 8, padBottom = 4, padLeft = 0, padRight = 0;
|
||||
const chartW = w - padLeft - padRight;
|
||||
const chartH = h - padTop - padBottom;
|
||||
|
||||
// 背景
|
||||
ctx.fillStyle = '#0c1222';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// 计算Y轴最大值
|
||||
const allValues = [...txArr, ...rxArr];
|
||||
let maxVal = allValues.length > 0 ? Math.max(...allValues) : 0;
|
||||
if (maxVal < 1024) maxVal = 1024; // 最小1KB
|
||||
// 向上取整到合适的刻度
|
||||
const niceMax = niceNumber(maxVal);
|
||||
|
||||
// 更新最大值标签
|
||||
const maxLabel = document.getElementById('chart-max-' + ip.replaceAll('.', '-'));
|
||||
if (maxLabel) maxLabel.textContent = '峰值: ' + formatSpeed(niceMax);
|
||||
|
||||
// 网格线
|
||||
const gridLines = 4;
|
||||
ctx.strokeStyle = 'rgba(71, 85, 105, 0.3)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i <= gridLines; i++) {
|
||||
const y = padTop + (chartH / gridLines) * i;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padLeft, y);
|
||||
ctx.lineTo(padLeft + chartW, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
// 垂直网格线
|
||||
const vLines = 6;
|
||||
for (let i = 0; i <= vLines; i++) {
|
||||
const x = padLeft + (chartW / vLines) * i;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, padTop);
|
||||
ctx.lineTo(x, padTop + chartH);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 绘制曲线
|
||||
const drawLine = (data, strokeColor, fillColor) => {
|
||||
if (data.length < 2) return;
|
||||
const step = chartW / (HISTORY_SIZE - 1);
|
||||
const offset = HISTORY_SIZE - data.length;
|
||||
|
||||
// 填充区域
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padLeft + offset * step, padTop + chartH);
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const x = padLeft + (offset + i) * step;
|
||||
const y = padTop + chartH - (data[i] / niceMax) * chartH;
|
||||
if (i === 0) ctx.lineTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.lineTo(padLeft + (offset + data.length - 1) * step, padTop + chartH);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = fillColor;
|
||||
ctx.fill();
|
||||
|
||||
// 线条
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const x = padLeft + (offset + i) * step;
|
||||
const y = padTop + chartH - (data[i] / niceMax) * chartH;
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.strokeStyle = strokeColor;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
drawLine(rxArr, '#60a5fa', 'rgba(96, 165, 250, 0.15)');
|
||||
drawLine(txArr, '#4ade80', 'rgba(74, 222, 128, 0.15)');
|
||||
};
|
||||
|
||||
// 将数值取整到适合的刻度
|
||||
const niceNumber = (val) => {
|
||||
const units = [
|
||||
1024, // 1KB
|
||||
10 * 1024, // 10KB
|
||||
100 * 1024, // 100KB
|
||||
1024 * 1024, // 1MB
|
||||
10 * 1024 * 1024, // 10MB
|
||||
100 * 1024 * 1024,// 100MB
|
||||
1024 * 1024 * 1024,// 1GB
|
||||
];
|
||||
for (const u of units) {
|
||||
if (val <= u) return u;
|
||||
}
|
||||
return Math.ceil(val / (1024 * 1024 * 1024)) * 1024 * 1024 * 1024;
|
||||
};
|
||||
|
||||
const fetchPeers = async () => {
|
||||
if (info.value.status !== "running") {
|
||||
peers.value = [];
|
||||
lastTrafficMap = {};
|
||||
lastFetchTime = 0;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/peers`);
|
||||
const json = await res.json();
|
||||
if (json.code === 0) {
|
||||
const now = Date.now();
|
||||
const list = json.data || [];
|
||||
const elapsed = lastFetchTime > 0 ? (now - lastFetchTime) / 1000 : 0;
|
||||
const newTrafficMap = {};
|
||||
for (const peer of list) {
|
||||
if (peer.traffic) {
|
||||
const key = peer.ip;
|
||||
const prev = lastTrafficMap[key];
|
||||
if (prev && elapsed > 0) {
|
||||
const txDiff = Math.max(0, peer.traffic.tx_bytes - prev.tx_bytes);
|
||||
const rxDiff = Math.max(0, peer.traffic.rx_bytes - prev.rx_bytes);
|
||||
peer.traffic.tx_speed = Math.round(txDiff / elapsed);
|
||||
peer.traffic.rx_speed = Math.round(rxDiff / elapsed);
|
||||
} else {
|
||||
peer.traffic.tx_speed = 0;
|
||||
peer.traffic.rx_speed = 0;
|
||||
}
|
||||
newTrafficMap[key] = { tx_bytes: peer.traffic.tx_bytes, rx_bytes: peer.traffic.rx_bytes };
|
||||
// 记录速度历史
|
||||
if (!speedHistoryMap[key]) speedHistoryMap[key] = { tx: [], rx: [] };
|
||||
speedHistoryMap[key].tx.push(peer.traffic.tx_speed);
|
||||
speedHistoryMap[key].rx.push(peer.traffic.rx_speed);
|
||||
if (speedHistoryMap[key].tx.length > HISTORY_SIZE) {
|
||||
speedHistoryMap[key].tx.shift();
|
||||
speedHistoryMap[key].rx.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
lastTrafficMap = newTrafficMap;
|
||||
lastFetchTime = now;
|
||||
peers.value = list;
|
||||
// 重绘所有展开的图表
|
||||
nextTick(() => {
|
||||
for (const ip in expandedPeers) {
|
||||
if (expandedPeers[ip]) drawChart(ip);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchPeers();
|
||||
timer = setInterval(() => {
|
||||
if (isPageVisible.value) fetchPeers();
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer);
|
||||
});
|
||||
|
||||
// 监听 status 变化,当变为 running 时立即获取数据
|
||||
watch(() => info.value.status, (newStatus) => {
|
||||
if (newStatus === "running") {
|
||||
fetchPeers();
|
||||
}
|
||||
});
|
||||
|
||||
const getRouteModeClass = (route) => {
|
||||
// route 包含 addr, protocol, metric, rtt
|
||||
// 判断是否直连:metric === 1
|
||||
const isDirect = route.metric === 1;
|
||||
|
||||
if (isDirect) {
|
||||
return 'text-purple-400 font-medium'; // 直连 - 紫色
|
||||
} else {
|
||||
return 'text-blue-400'; // 客户端中继 - 蓝色
|
||||
}
|
||||
};
|
||||
|
||||
const getRouteModeText = (route) => {
|
||||
const isDirect = route.metric === 1;
|
||||
const isTcp = route.protocol.includes('Tcp');
|
||||
|
||||
if (isDirect) {
|
||||
return isTcp ? '打洞TCP直连' : '打洞UDP直连';
|
||||
} else {
|
||||
return isTcp ? '客户端TCP中继' : '客户端UDP中继';
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
peers,
|
||||
expandedPeers,
|
||||
toggleExpand,
|
||||
formatTime,
|
||||
formatBytes,
|
||||
formatSpeed,
|
||||
getRouteModeClass,
|
||||
getRouteModeText,
|
||||
showPeerTooltip: showTooltipGlobal,
|
||||
hidePeerTooltip: hideTooltipGlobal,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const RoutesView = {
|
||||
template: "#tpl-routes",
|
||||
setup() {
|
||||
const routes = ref([]);
|
||||
const info = inject("info");
|
||||
const isPageVisible = inject("isPageVisible");
|
||||
let timer = null;
|
||||
|
||||
const fetchRoutes = async () => {
|
||||
if (info.value.status !== "running") {
|
||||
routes.value = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/routes`);
|
||||
const json = await res.json();
|
||||
if (json.code === 0) routes.value = json.data || [];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchRoutes();
|
||||
timer = setInterval(() => {
|
||||
if (isPageVisible.value) fetchRoutes();
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer);
|
||||
});
|
||||
|
||||
// 监听 status 变化,当变为 running 时立即获取数据
|
||||
watch(() => info.value.status, (newStatus) => {
|
||||
if (newStatus === "running") {
|
||||
fetchRoutes();
|
||||
}
|
||||
});
|
||||
|
||||
return {routes};
|
||||
},
|
||||
};
|
||||
|
||||
// --- 路由配置 ---
|
||||
|
||||
const routes = [
|
||||
{path: "/", redirect: "/general"},
|
||||
{path: "/general", component: GeneralView},
|
||||
{path: "/config", component: ConfigView},
|
||||
{path: "/peers", component: PeersView},
|
||||
{path: "/routes", component: RoutesView},
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
});
|
||||
|
||||
// --- 主应用 ---
|
||||
|
||||
createApp({
|
||||
setup() {
|
||||
const info = ref({
|
||||
status: "stopped",
|
||||
ip: "",
|
||||
name: "",
|
||||
device_id: "",
|
||||
version: "",
|
||||
server_info: [],
|
||||
online_client_num: 0,
|
||||
direct_client_num: 0,
|
||||
offline_client_num: 0,
|
||||
current_config_file: null,
|
||||
current_config_name: null,
|
||||
});
|
||||
const configList = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
// 启动日志相关
|
||||
const showStartLog = ref(false);
|
||||
const startLogs = ref([]);
|
||||
const startStatus = ref("stopped");
|
||||
const logContainer = ref(null);
|
||||
let statusInterval = null;
|
||||
let infoTimer = null;
|
||||
|
||||
// Tooltip 相关
|
||||
const tooltipState = ref({
|
||||
show: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
info: null,
|
||||
});
|
||||
let tooltipHideTimer = null;
|
||||
const showPeerTooltip = (event, peer) => {
|
||||
if (!peer.nat_info) return;
|
||||
if (tooltipHideTimer) {
|
||||
clearTimeout(tooltipHideTimer);
|
||||
tooltipHideTimer = null;
|
||||
}
|
||||
const rect =
|
||||
event.currentTarget.getBoundingClientRect();
|
||||
tooltipState.value = {
|
||||
show: true,
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.bottom + 10,
|
||||
info: peer.nat_info,
|
||||
};
|
||||
};
|
||||
const hidePeerTooltip = () => {
|
||||
tooltipHideTimer = setTimeout(() => {
|
||||
tooltipState.value.show = false;
|
||||
}, 100);
|
||||
};
|
||||
const onTooltipEnter = () => {
|
||||
if (tooltipHideTimer) {
|
||||
clearTimeout(tooltipHideTimer);
|
||||
tooltipHideTimer = null;
|
||||
}
|
||||
};
|
||||
const onTooltipLeave = () => {
|
||||
tooltipState.value.show = false;
|
||||
};
|
||||
|
||||
// 计算属性
|
||||
const isServerConnected = computed(
|
||||
() =>
|
||||
info.value.server_info &&
|
||||
info.value.server_info.some((s) => s.connected),
|
||||
);
|
||||
const serverStatusText = computed(() => {
|
||||
if (
|
||||
!info.value.server_info ||
|
||||
!info.value.server_info.length
|
||||
)
|
||||
return "未配置服务器";
|
||||
return `${info.value.server_info.filter((s) => s.connected).length} / ${info.value.server_info.length} 已连接`;
|
||||
});
|
||||
|
||||
// 基础 API
|
||||
const fetchInfo = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/info`);
|
||||
const json = await res.json();
|
||||
if (json.code === 0) info.value = json.data;
|
||||
} catch (e) {
|
||||
console.error("Fetch info error", e);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchConfigList = async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/config/list`,
|
||||
);
|
||||
const json = await res.json();
|
||||
if (json.code === 0) configList.value = json.data;
|
||||
} catch (e) {
|
||||
console.error("Fetch list error", e);
|
||||
}
|
||||
};
|
||||
|
||||
// 启动流程控制
|
||||
const pollStartStatus = async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/start/status`,
|
||||
);
|
||||
const json = await res.json();
|
||||
if (json.code === 0) {
|
||||
startLogs.value = json.data.logs || [];
|
||||
startStatus.value = json.data.status;
|
||||
nextTick(() => {
|
||||
if (logContainer.value)
|
||||
logContainer.value.scrollTop =
|
||||
logContainer.value.scrollHeight;
|
||||
});
|
||||
|
||||
if (startStatus.value === "running") {
|
||||
stopPolling();
|
||||
fetchInfo();
|
||||
showStartLog.value = false;
|
||||
} else if (
|
||||
startStatus.value === "stopped" &&
|
||||
startLogs.value.length > 0
|
||||
) {
|
||||
stopPolling();
|
||||
fetchInfo();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const stopPolling = () => {
|
||||
if (statusInterval) {
|
||||
clearInterval(statusInterval);
|
||||
statusInterval = null;
|
||||
}
|
||||
};
|
||||
const startPolling = () => {
|
||||
stopPolling();
|
||||
statusInterval = setInterval(pollStartStatus, 1000);
|
||||
pollStartStatus();
|
||||
};
|
||||
|
||||
const openStartingModal = () => {
|
||||
startLogs.value = [];
|
||||
startStatus.value = "starting";
|
||||
showStartLog.value = true;
|
||||
startPolling();
|
||||
};
|
||||
|
||||
const toggleVnt = async (selectedFile) => {
|
||||
if (loading.value) return;
|
||||
|
||||
// 停止逻辑
|
||||
if (
|
||||
info.value.status === "running" ||
|
||||
info.value.status === "starting"
|
||||
) {
|
||||
loading.value = true;
|
||||
await fetch(`${API_BASE}/api/stop`, {
|
||||
method: "POST",
|
||||
});
|
||||
loading.value = false;
|
||||
stopPolling();
|
||||
showStartLog.value = false;
|
||||
fetchInfo();
|
||||
return;
|
||||
}
|
||||
|
||||
// 启动逻辑
|
||||
if (!selectedFile) {
|
||||
alert("请先选择一个配置");
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/start`, {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({
|
||||
file_name: selectedFile,
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
loading.value = false;
|
||||
if (json.code !== 0) {
|
||||
alert("启动失败: " + json.msg);
|
||||
return;
|
||||
}
|
||||
openStartingModal();
|
||||
} catch (e) {
|
||||
loading.value = false;
|
||||
alert("网络请求失败: " + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelStart = async () => {
|
||||
stopPolling();
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/stop`, {
|
||||
method: "POST",
|
||||
});
|
||||
startLogs.value.push("启动已手动取消");
|
||||
} catch (e) {
|
||||
}
|
||||
startStatus.value = "stopped";
|
||||
fetchInfo();
|
||||
};
|
||||
|
||||
const restartVnt = async (selectedFile) => {
|
||||
if (loading.value) return;
|
||||
if (!selectedFile) {
|
||||
alert("请先选择一个配置");
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/restart`, {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({
|
||||
file_name: selectedFile,
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
loading.value = false;
|
||||
if (json.code !== 0) {
|
||||
alert("重启失败: " + json.msg);
|
||||
return;
|
||||
}
|
||||
openStartingModal();
|
||||
} catch (e) {
|
||||
loading.value = false;
|
||||
alert("网络请求失败: " + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 导航辅助
|
||||
const navClass = (isActive) =>
|
||||
isActive
|
||||
? "bg-blue-600 text-white shadow-lg shadow-blue-500/30"
|
||||
: "text-slate-400 hover:bg-slate-800 hover:text-white";
|
||||
|
||||
// 页面可见性处理
|
||||
const isPageVisible = ref(!document.hidden);
|
||||
const visibilityHandler = () => {
|
||||
isPageVisible.value = !document.hidden;
|
||||
};
|
||||
|
||||
// 依赖注入
|
||||
provide("info", info);
|
||||
provide("isPageVisible", isPageVisible);
|
||||
provide("configList", configList);
|
||||
provide("fetchConfigList", fetchConfigList);
|
||||
provide("toggleVnt", toggleVnt);
|
||||
provide("restartVnt", restartVnt);
|
||||
provide("loading", loading);
|
||||
provide("showPeerTooltip", showPeerTooltip);
|
||||
provide("hidePeerTooltip", hidePeerTooltip);
|
||||
|
||||
onMounted(async () => {
|
||||
document.addEventListener(
|
||||
"visibilitychange",
|
||||
visibilityHandler,
|
||||
);
|
||||
await fetchInfo();
|
||||
fetchConfigList();
|
||||
if (info.value.status === "starting")
|
||||
openStartingModal();
|
||||
|
||||
// 全局轮询 info (状态/IP等)
|
||||
infoTimer = setInterval(() => {
|
||||
if (info.value.status !== "running") return;
|
||||
if (isPageVisible.value) fetchInfo();
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener(
|
||||
"visibilitychange",
|
||||
visibilityHandler,
|
||||
);
|
||||
stopPolling();
|
||||
if (infoTimer) clearInterval(infoTimer);
|
||||
});
|
||||
|
||||
return {
|
||||
info,
|
||||
isServerConnected,
|
||||
serverStatusText,
|
||||
navClass,
|
||||
showStartLog,
|
||||
startStatus,
|
||||
startLogs,
|
||||
logContainer,
|
||||
cancelStart,
|
||||
tooltipState,
|
||||
onTooltipEnter,
|
||||
onTooltipLeave,
|
||||
};
|
||||
},
|
||||
})
|
||||
.use(router)
|
||||
.mount("#app");
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VNT Dashboard</title>
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect x='0' y='0' width='64' height='64' rx='16' fill='%23ffffff' stroke='%23e2e8f0' stroke-width='2'/%3E%3Cpath d='M16 20 L32 48 L48 20' fill='none' stroke='%234f46e5' stroke-width='6' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='16' cy='20' r='6' fill='%236366f1'/%3E%3Ccircle cx='48' cy='20' r='6' fill='%236366f1'/%3E%3Ccircle cx='32' cy='48' r='6' fill='%2322c55e'/%3E%3C/svg%3E"
|
||||
type="image/svg+xml"
|
||||
/>
|
||||
<script>
|
||||
// 首屏前应用主题,避免闪烁
|
||||
(function () {
|
||||
var t = localStorage.getItem("vnt-theme");
|
||||
var dark = t
|
||||
? t === "dark"
|
||||
: window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
if (dark) document.documentElement.classList.add("dark");
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "vnt-web-ui",
|
||||
"private": true,
|
||||
"version": "2.0.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "catalog:",
|
||||
"qrcode": "1.5.4",
|
||||
"vue": "catalog:",
|
||||
"vue-router": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@vitejs/plugin-vue": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"vite": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useAppStore } from "./stores/app";
|
||||
import { useStartLogStore } from "./stores/startLog";
|
||||
import { visibleNavItems } from "./navigation";
|
||||
import AppSidebar from "./components/AppSidebar.vue";
|
||||
import AppModal from "./components/AppModal.vue";
|
||||
import AppTooltip from "./components/AppTooltip.vue";
|
||||
import ConfirmHost from "./components/ConfirmHost.vue";
|
||||
import ToastHost from "./components/ToastHost.vue";
|
||||
import AccessGate from "./components/AccessGate.vue";
|
||||
import { authorized, isDesktop } from "./auth";
|
||||
|
||||
const app = useAppStore();
|
||||
const startLog = useStartLogStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const tooltipRef = ref(null);
|
||||
provide("peerTooltip", tooltipRef);
|
||||
|
||||
const mobileNavOpen = ref(false);
|
||||
const items = visibleNavItems();
|
||||
const pageMeta = computed(() => items.find((item) => item.to === route.path) || items[0]);
|
||||
|
||||
const savedTheme = localStorage.getItem("vnt-theme");
|
||||
const isDark = ref(savedTheme
|
||||
? savedTheme === "dark"
|
||||
: window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
const applyTheme = () => document.documentElement.classList.toggle("dark", isDark.value);
|
||||
const toggleTheme = () => {
|
||||
isDark.value = !isDark.value;
|
||||
localStorage.setItem("vnt-theme", isDark.value ? "dark" : "light");
|
||||
applyTheme();
|
||||
};
|
||||
applyTheme();
|
||||
|
||||
watch(() => route.path, () => { mobileNavOpen.value = false; });
|
||||
|
||||
const handleKeydown = (event) => {
|
||||
if (!(event.ctrlKey || event.metaKey) || event.altKey) return;
|
||||
const index = Number(event.key) - 1;
|
||||
if (index >= 0 && index < items.length) {
|
||||
event.preventDefault();
|
||||
router.push(items[index].to);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => window.addEventListener("keydown", handleKeydown));
|
||||
onBeforeUnmount(() => window.removeEventListener("keydown", handleKeydown));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccessGate v-if="!isDesktop && !authorized" />
|
||||
<div v-else class="flex h-[100dvh] min-h-0 overflow-hidden bg-slate-50 text-slate-700 dark:bg-slate-950 dark:text-slate-200">
|
||||
<aside class="hidden w-60 shrink-0 border-r border-slate-200 dark:border-slate-800 lg:block">
|
||||
<AppSidebar />
|
||||
</aside>
|
||||
|
||||
<transition name="drawer">
|
||||
<div v-if="mobileNavOpen" class="fixed inset-0 z-40 lg:hidden">
|
||||
<button class="absolute inset-0 bg-slate-950/45 backdrop-blur-[2px]" aria-label="关闭导航" @click="mobileNavOpen = false"></button>
|
||||
<aside class="drawer-panel relative h-full w-[min(82vw,288px)] border-r border-slate-200 shadow-2xl dark:border-slate-700">
|
||||
<AppSidebar @navigate="mobileNavOpen = false" />
|
||||
</aside>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<header class="flex h-16 shrink-0 items-center gap-3 border-b border-slate-200 bg-white/90 px-4 backdrop-blur lg:px-6 dark:border-slate-800 dark:bg-slate-900/90">
|
||||
<button
|
||||
class="grid h-9 w-9 shrink-0 place-items-center rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-900 lg:hidden dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
|
||||
aria-label="打开导航"
|
||||
@click="mobileNavOpen = true"
|
||||
>
|
||||
<svg class="h-5 w-5 fill-none stroke-current" viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h16" stroke-linecap="round" stroke-width="2" /></svg>
|
||||
</button>
|
||||
|
||||
<div class="min-w-0">
|
||||
<h1 class="truncate text-lg font-bold text-slate-900 dark:text-white">{{ pageMeta.label }}</h1>
|
||||
<p class="hidden text-xs text-slate-400 sm:block">{{ pageMeta.subtitle || "VNT 虚拟局域网管理" }}</p>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<div class="flex h-8 items-center gap-2 rounded-lg border border-slate-200 bg-white px-2.5 text-xs font-medium text-slate-500 sm:px-3 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">
|
||||
<span
|
||||
class="h-1.5 w-1.5 rounded-full"
|
||||
:class="app.runningCount > 0 ? 'bg-green-500' : app.startingCount > 0 ? 'animate-pulse bg-amber-400' : 'bg-slate-300 dark:bg-slate-600'"
|
||||
></span>
|
||||
<span class="hidden sm:inline">{{ app.headerStatusText }}</span>
|
||||
</div>
|
||||
<button
|
||||
class="grid h-8 w-8 place-items-center rounded-lg border border-slate-200 bg-white text-slate-500 hover:border-indigo-300 hover:text-indigo-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:border-indigo-500 dark:hover:text-indigo-400"
|
||||
:title="isDark ? '切换浅色模式' : '切换深色模式'"
|
||||
:aria-label="isDark ? '切换浅色模式' : '切换深色模式'"
|
||||
@click="toggleTheme"
|
||||
>
|
||||
<svg v-if="isDark" class="h-4 w-4 fill-none stroke-current" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M4.9 4.9l1.4 1.4m11.4 11.4 1.4 1.4M2 12h2m16 0h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" stroke-linecap="round" stroke-width="1.7"/></svg>
|
||||
<svg v-else class="h-4 w-4 fill-none stroke-current" viewBox="0 0 24 24"><path d="M20.5 15.2A8.5 8.5 0 0 1 8.8 3.5 8.5 8.5 0 1 0 20.5 15.2Z" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.7"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="custom-scrollbar min-h-0 flex-1 overflow-x-hidden overflow-y-auto">
|
||||
<div class="mx-auto w-full max-w-[1700px] px-4 py-5 sm:px-5 lg:px-7 lg:py-6">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in"><component :is="Component" /></transition>
|
||||
</router-view>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<AppModal
|
||||
:show="startLog.showStartLog"
|
||||
:mask-closable="startLog.startStatus !== 'starting'"
|
||||
:esc-closable="startLog.startStatus !== 'starting'"
|
||||
panel-class="w-full max-w-2xl"
|
||||
@close="startLog.close"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<span
|
||||
class="h-2 w-2 shrink-0 rounded-full"
|
||||
:class="startLog.startStatus === 'starting' ? 'animate-pulse bg-amber-400' : startLog.startStatus === 'running' ? 'bg-green-500' : 'bg-red-500'"
|
||||
></span>
|
||||
<h3 class="truncate text-base font-bold text-slate-900 sm:text-lg dark:text-white">
|
||||
{{ startLog.startStatus === "starting" ? "正在建立虚拟网络" : startLog.startStatus === "running" ? "网络已连接" : "连接未完成" }}
|
||||
</h3>
|
||||
</div>
|
||||
<span class="max-w-[40%] truncate font-mono text-xs text-indigo-600 dark:text-indigo-400">{{ startLog.logConfigName }}</span>
|
||||
</template>
|
||||
<template #body>
|
||||
<div
|
||||
:ref="(el) => (startLog.logContainer = el)"
|
||||
class="custom-scrollbar h-64 space-y-2 overflow-y-auto border-y border-slate-200 bg-slate-50 p-4 font-mono text-xs text-slate-600 sm:h-80 sm:p-6 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-300"
|
||||
>
|
||||
<div v-for="(log, idx) in startLog.startLogs" :key="idx" class="flex gap-3">
|
||||
<span class="text-indigo-600 dark:text-indigo-400">›</span><span class="break-all">{{ log }}</span>
|
||||
</div>
|
||||
<div v-if="startLog.startStatus === 'starting'" class="animate-pulse text-indigo-600 dark:text-indigo-400">等待下一阶段…</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<button v-if="startLog.startStatus === 'starting'" class="btn-ghost" @click="startLog.cancelStart">取消连接</button>
|
||||
<button v-else class="btn-primary" @click="startLog.close">完成</button>
|
||||
</template>
|
||||
</AppModal>
|
||||
|
||||
<ToastHost />
|
||||
<ConfirmHost />
|
||||
<AppTooltip ref="tooltipRef" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,95 @@
|
||||
import { clearAccessToken, getAccessToken } from "../auth";
|
||||
|
||||
// HTTP 与 Tauri IPC 共用相同的 ApiResponse{code,msg,data} 协议。
|
||||
const request = async (url, options = {}) => {
|
||||
if (globalThis.__VNT_IPC_REQUEST__) {
|
||||
const json = await globalThis.__VNT_IPC_REQUEST__({
|
||||
method: options.method || "GET",
|
||||
path: url,
|
||||
body: options.body || null,
|
||||
});
|
||||
if (json.code !== 0) throw new Error(json.msg || "请求失败");
|
||||
return json.data;
|
||||
}
|
||||
|
||||
const headers = new Headers(options.headers || {});
|
||||
const token = getAccessToken();
|
||||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||
const res = await fetch(url, { ...options, headers });
|
||||
const json = await res.json();
|
||||
if (res.status === 401) clearAccessToken();
|
||||
if (json.code !== 0) {
|
||||
throw new Error(json.msg || "请求失败");
|
||||
}
|
||||
return json.data;
|
||||
};
|
||||
|
||||
const jsonHeaders = { "Content-Type": "application/json" };
|
||||
|
||||
// GET /api/info?file_name=
|
||||
export const getInstanceInfo = (fileName) =>
|
||||
request(`/api/info?file_name=${encodeURIComponent(fileName)}`);
|
||||
|
||||
// GET /api/peers?file_name=
|
||||
export const getPeers = (fileName) =>
|
||||
request(`/api/peers?file_name=${encodeURIComponent(fileName)}`);
|
||||
|
||||
// GET /api/routes?file_name=
|
||||
export const getRoutes = (fileName) =>
|
||||
request(`/api/routes?file_name=${encodeURIComponent(fileName)}`);
|
||||
|
||||
// GET /api/start/status?file_name=
|
||||
export const getStartStatus = (fileName) =>
|
||||
request(`/api/start/status?file_name=${encodeURIComponent(fileName)}`);
|
||||
|
||||
// GET /api/version
|
||||
export const getVersion = () => request("/api/version");
|
||||
|
||||
// GET /api/runtime
|
||||
export const getRuntime = () => request("/api/runtime");
|
||||
|
||||
// GET /api/instances
|
||||
export const getInstances = () => request("/api/instances");
|
||||
|
||||
// DELETE /api/instance?file_name=
|
||||
export const deleteInstance = (fileName) =>
|
||||
request(`/api/instance?file_name=${encodeURIComponent(fileName)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
const postAction = (path, fileName) =>
|
||||
request(path, {
|
||||
method: "POST",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({ file_name: fileName }),
|
||||
});
|
||||
|
||||
// POST /api/start
|
||||
export const startVntApi = (fileName) => postAction("/api/start", fileName);
|
||||
|
||||
// POST /api/stop
|
||||
export const stopVntApi = (fileName) => postAction("/api/stop", fileName);
|
||||
|
||||
// POST /api/restart
|
||||
export const restartVntApi = (fileName) => postAction("/api/restart", fileName);
|
||||
|
||||
// GET /api/config/list
|
||||
export const getConfigList = () => request("/api/config/list");
|
||||
|
||||
// GET /api/config?file_name= -> TOML 原文字符串
|
||||
export const getConfig = (fileName) =>
|
||||
request(`/api/config?file_name=${encodeURIComponent(fileName)}`);
|
||||
|
||||
// POST /api/config, JSON {file_name?, config}
|
||||
export const saveConfig = (fileName, config) =>
|
||||
request("/api/config", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({ file_name: fileName || null, config }),
|
||||
});
|
||||
|
||||
// DELETE /api/config?file_name=
|
||||
export const deleteConfig = (fileName) =>
|
||||
request(`/api/config?file_name=${encodeURIComponent(fileName)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,32 @@
|
||||
import { ref } from "vue";
|
||||
|
||||
const STORAGE_KEY = "vnt-web-access-token";
|
||||
export const isDesktop = Boolean(globalThis.__VNT_DESKTOP__);
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
const tokenFromUrl = url.searchParams.get("token") || "";
|
||||
if (tokenFromUrl) {
|
||||
localStorage.setItem(STORAGE_KEY, tokenFromUrl);
|
||||
url.searchParams.delete("token");
|
||||
window.history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`);
|
||||
}
|
||||
|
||||
export const accessToken = ref(
|
||||
isDesktop ? "" : tokenFromUrl || localStorage.getItem(STORAGE_KEY) || "",
|
||||
);
|
||||
export const authorized = ref(isDesktop || Boolean(accessToken.value));
|
||||
|
||||
export const getAccessToken = () => accessToken.value;
|
||||
|
||||
export const saveAccessToken = (token) => {
|
||||
const normalized = token.trim();
|
||||
localStorage.setItem(STORAGE_KEY, normalized);
|
||||
accessToken.value = normalized;
|
||||
authorized.value = Boolean(normalized);
|
||||
};
|
||||
|
||||
export const clearAccessToken = () => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
accessToken.value = "";
|
||||
authorized.value = false;
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { saveAccessToken } from "../auth";
|
||||
import vntIcon from "../assets/vnt-icon.png";
|
||||
|
||||
const token = ref("");
|
||||
const submit = () => {
|
||||
if (!token.value.trim()) return;
|
||||
saveAccessToken(token.value);
|
||||
window.location.reload();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="grid h-[100dvh] place-items-center overflow-y-auto bg-slate-50 p-4 text-slate-700 dark:bg-slate-950 dark:text-slate-200">
|
||||
<form class="w-full max-w-md rounded-2xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8 dark:border-slate-800 dark:bg-slate-900" @submit.prevent="submit">
|
||||
<div class="mb-7 flex items-center gap-3">
|
||||
<img :src="vntIcon" alt="" class="h-10 w-10 shrink-0" />
|
||||
<div>
|
||||
<h1 class="text-lg font-bold text-slate-900 dark:text-white">访问 VNT 控制台</h1>
|
||||
<p class="mt-0.5 text-xs text-slate-400">请输入桌面端 Web 访问设置中的令牌</p>
|
||||
</div>
|
||||
</div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-200" for="access-token">访问令牌</label>
|
||||
<input id="access-token" v-model="token" class="input font-mono" type="password" autocomplete="current-password" autofocus placeholder="粘贴访问令牌" />
|
||||
<button class="btn-primary mt-5 w-full" type="submit" :disabled="!token.trim()">进入控制台</button>
|
||||
<p class="mt-5 text-center text-xs leading-5 text-slate-400">令牌只保存在当前浏览器中,可随时在桌面端重新生成。</p>
|
||||
</form>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup>
|
||||
import { watch, onUnmounted } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
// 点击遮罩是否关闭
|
||||
maskClosable: { type: Boolean, default: true },
|
||||
// ESC 是否关闭
|
||||
escClosable: { type: Boolean, default: true },
|
||||
panelClass: { type: String, default: "w-full max-w-2xl" },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close"]);
|
||||
|
||||
const onKeydown = (e) => {
|
||||
if (props.escClosable && e.key === "Escape") emit("close");
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(val) => {
|
||||
if (val) window.addEventListener("keydown", onKeydown);
|
||||
else window.removeEventListener("keydown", onKeydown);
|
||||
},
|
||||
);
|
||||
|
||||
onUnmounted(() => window.removeEventListener("keydown", onKeydown));
|
||||
|
||||
const onMaskClick = () => {
|
||||
if (props.maskClosable) emit("close");
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<transition name="modal">
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 p-4 backdrop-blur-sm"
|
||||
@click.self="onMaskClick"
|
||||
>
|
||||
<div
|
||||
class="modal-panel flex max-h-[90vh] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white shadow-2xl dark:border-slate-700 dark:bg-slate-900"
|
||||
:class="panelClass"
|
||||
>
|
||||
<div
|
||||
v-if="$slots.header"
|
||||
class="flex shrink-0 items-center justify-between gap-3 border-b border-slate-200 bg-slate-50/60 px-6 py-4 dark:border-slate-700 dark:bg-slate-800/50"
|
||||
>
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<div class="custom-scrollbar min-h-0 flex-1 overflow-y-auto">
|
||||
<slot name="body" />
|
||||
</div>
|
||||
<div
|
||||
v-if="$slots.footer"
|
||||
class="flex shrink-0 items-center justify-end gap-3 border-t border-slate-200 bg-slate-50/60 px-6 py-4 dark:border-slate-700 dark:bg-slate-800/50"
|
||||
>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,183 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from "vue";
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { default: null },
|
||||
options: { type: Array, default: () => [] },
|
||||
placeholder: { type: String, default: "请选择" },
|
||||
disabled: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
const root = ref(null);
|
||||
const trigger = ref(null);
|
||||
const open = ref(false);
|
||||
const activeIndex = ref(-1);
|
||||
const listboxId = `app-select-${useId().replaceAll(":", "")}`;
|
||||
|
||||
const selectedIndex = computed(() =>
|
||||
props.options.findIndex((option) => Object.is(option.value, props.modelValue)),
|
||||
);
|
||||
const selectedOption = computed(() => props.options[selectedIndex.value] || null);
|
||||
|
||||
const firstEnabledIndex = (from, direction) => {
|
||||
if (!props.options.length) return -1;
|
||||
let index = from;
|
||||
for (let count = 0; count < props.options.length; count += 1) {
|
||||
index = (index + direction + props.options.length) % props.options.length;
|
||||
if (!props.options[index]?.disabled) return index;
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
const openMenu = async () => {
|
||||
if (props.disabled) return;
|
||||
open.value = true;
|
||||
activeIndex.value = selectedIndex.value >= 0
|
||||
? selectedIndex.value
|
||||
: firstEnabledIndex(-1, 1);
|
||||
await nextTick();
|
||||
root.value?.querySelector(`[data-option-index="${activeIndex.value}"]`)?.scrollIntoView({ block: "nearest" });
|
||||
};
|
||||
|
||||
const closeMenu = (restoreFocus = false) => {
|
||||
open.value = false;
|
||||
if (restoreFocus) trigger.value?.focus();
|
||||
};
|
||||
|
||||
const choose = (option) => {
|
||||
if (option.disabled) return;
|
||||
emit("update:modelValue", option.value);
|
||||
closeMenu(true);
|
||||
};
|
||||
|
||||
const moveActive = (direction) => {
|
||||
activeIndex.value = firstEnabledIndex(activeIndex.value, direction);
|
||||
nextTick(() => {
|
||||
root.value?.querySelector(`[data-option-index="${activeIndex.value}"]`)?.scrollIntoView({ block: "nearest" });
|
||||
});
|
||||
};
|
||||
|
||||
const onKeydown = (event) => {
|
||||
if (props.disabled) return;
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
if (!open.value) openMenu();
|
||||
else moveActive(event.key === "ArrowDown" ? 1 : -1);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
if (!open.value) openMenu();
|
||||
else if (activeIndex.value >= 0) choose(props.options[activeIndex.value]);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape" && open.value) {
|
||||
event.preventDefault();
|
||||
closeMenu(true);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Home" && open.value) {
|
||||
event.preventDefault();
|
||||
activeIndex.value = firstEnabledIndex(-1, 1);
|
||||
} else if (event.key === "End" && open.value) {
|
||||
event.preventDefault();
|
||||
activeIndex.value = firstEnabledIndex(0, -1);
|
||||
} else if (event.key === "Tab") {
|
||||
closeMenu();
|
||||
}
|
||||
};
|
||||
|
||||
const onDocumentPointerDown = (event) => {
|
||||
if (open.value && !root.value?.contains(event.target)) closeMenu();
|
||||
};
|
||||
|
||||
watch(() => props.disabled, (disabled) => {
|
||||
if (disabled) closeMenu();
|
||||
});
|
||||
onMounted(() => document.addEventListener("pointerdown", onDocumentPointerDown));
|
||||
onBeforeUnmount(() => document.removeEventListener("pointerdown", onDocumentPointerDown));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="relative w-full">
|
||||
<button
|
||||
ref="trigger"
|
||||
v-bind="$attrs"
|
||||
type="button"
|
||||
role="combobox"
|
||||
:aria-expanded="open"
|
||||
:aria-controls="listboxId"
|
||||
aria-haspopup="listbox"
|
||||
:disabled="disabled"
|
||||
class="input flex min-h-10 items-center justify-between gap-3 text-left"
|
||||
:class="open ? 'border-indigo-500 ring-2 ring-indigo-500/25' : ''"
|
||||
@click="open ? closeMenu() : openMenu()"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate" :class="selectedOption ? '' : 'text-slate-400 dark:text-slate-500'">
|
||||
{{ selectedOption?.label || placeholder }}
|
||||
</span>
|
||||
<svg
|
||||
class="h-4 w-4 shrink-0 fill-none stroke-current text-slate-400 transition-transform duration-150"
|
||||
:class="open ? 'rotate-180 text-indigo-500' : ''"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m7 10 5 5 5-5" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<transition name="select-menu">
|
||||
<div
|
||||
v-if="open"
|
||||
:id="listboxId"
|
||||
role="listbox"
|
||||
class="custom-scrollbar absolute z-40 mt-1.5 max-h-60 w-full overflow-y-auto rounded-xl border border-slate-200 bg-white p-1.5 shadow-xl shadow-slate-900/10 dark:border-slate-700 dark:bg-slate-800 dark:shadow-black/30"
|
||||
>
|
||||
<button
|
||||
v-for="(option, index) in options"
|
||||
:key="`${String(option.value)}-${index}`"
|
||||
type="button"
|
||||
role="option"
|
||||
:aria-selected="Object.is(option.value, modelValue)"
|
||||
:disabled="option.disabled"
|
||||
:data-option-index="index"
|
||||
class="flex w-full items-center gap-2 rounded-lg px-3 py-2.5 text-left text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-40"
|
||||
:class="[
|
||||
Object.is(option.value, modelValue)
|
||||
? 'bg-indigo-50 font-medium text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300'
|
||||
: 'text-slate-600 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-700/70',
|
||||
activeIndex === index && !Object.is(option.value, modelValue)
|
||||
? 'bg-slate-100 dark:bg-slate-700/70'
|
||||
: '',
|
||||
]"
|
||||
@mouseenter="activeIndex = index"
|
||||
@click="choose(option)"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">{{ option.label }}</span>
|
||||
<svg v-if="Object.is(option.value, modelValue)" class="h-4 w-4 shrink-0 fill-none stroke-current" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="m5 12 4 4L19 6" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" />
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="options.length === 0" class="px-3 py-4 text-center text-xs text-slate-400">暂无可选项</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.select-menu-enter-active,
|
||||
.select-menu-leave-active {
|
||||
transform-origin: top;
|
||||
transition: opacity 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.select-menu-enter-from,
|
||||
.select-menu-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px) scale(0.98);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup>
|
||||
import { useRoute } from "vue-router";
|
||||
import { useAppStore } from "../stores/app";
|
||||
import { visibleNavItems } from "../navigation";
|
||||
import vntIcon from "../assets/vnt-icon.png";
|
||||
|
||||
defineEmits(["navigate"]);
|
||||
const route = useRoute();
|
||||
const app = useAppStore();
|
||||
const items = visibleNavItems();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col bg-white dark:bg-slate-900">
|
||||
<div class="flex h-16 shrink-0 items-center gap-3 border-b border-slate-200 px-5 dark:border-slate-800">
|
||||
<img :src="vntIcon" alt="" class="h-8 w-8 shrink-0" />
|
||||
<div>
|
||||
<div class="text-sm font-bold tracking-wide text-slate-900 dark:text-white">VNT</div>
|
||||
<div class="text-[9px] font-semibold tracking-[0.18em] text-slate-400">CONTROL CENTER</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-3 mt-4 flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-3 py-3 dark:border-slate-700 dark:bg-slate-800/60">
|
||||
<span
|
||||
class="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
:class="app.runningCount > 0 ? 'bg-green-500' : app.startingCount > 0 ? 'animate-pulse bg-amber-400' : 'bg-slate-300 dark:bg-slate-600'"
|
||||
></span>
|
||||
<div class="min-w-0">
|
||||
<div class="text-[9px] font-semibold tracking-wider text-slate-400">虚拟网络</div>
|
||||
<div class="mt-0.5 truncate text-xs font-medium text-slate-700 dark:text-slate-200">{{ app.headerStatusText }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="mt-4 flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto px-3" aria-label="主导航">
|
||||
<router-link
|
||||
v-for="item in items"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="flex min-h-10 items-center gap-3 rounded-lg px-3 text-sm font-medium transition-colors"
|
||||
:class="route.path === item.to
|
||||
? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-300'
|
||||
: 'text-slate-500 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white'"
|
||||
@click="$emit('navigate')"
|
||||
>
|
||||
<svg class="h-[18px] w-[18px] shrink-0 fill-none stroke-current" viewBox="0 0 24 24">
|
||||
<path :d="item.icon" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.7" />
|
||||
</svg>
|
||||
<span>{{ item.label }}</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
|
||||
<div class="mx-3 mt-3 shrink-0 border-t border-slate-200 px-1 py-4 dark:border-slate-800">
|
||||
<div class="flex items-center gap-2 text-xs text-slate-400">
|
||||
<span class="h-1.5 w-1.5 rounded-full" :class="app.version ? 'bg-green-500' : 'bg-amber-400'"></span>
|
||||
<span>{{ app.version ? "本地服务正常" : "正在连接服务…" }}</span>
|
||||
<span class="ml-auto font-mono text-[10px]">v{{ app.version || "2.0" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
|
||||
// 全局 NAT tooltip(保留原定位/悬停逻辑)
|
||||
const tooltipState = ref({ show: false, x: 0, y: 0, info: null });
|
||||
let tooltipHideTimer = null;
|
||||
|
||||
const showPeerTooltip = (event, peer) => {
|
||||
if (!peer.nat_info) return;
|
||||
if (tooltipHideTimer) {
|
||||
clearTimeout(tooltipHideTimer);
|
||||
tooltipHideTimer = null;
|
||||
}
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
tooltipState.value = {
|
||||
show: true,
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.bottom + 10,
|
||||
info: peer.nat_info,
|
||||
};
|
||||
};
|
||||
|
||||
const hidePeerTooltip = () => {
|
||||
tooltipHideTimer = setTimeout(() => {
|
||||
tooltipState.value.show = false;
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const onTooltipEnter = () => {
|
||||
if (tooltipHideTimer) {
|
||||
clearTimeout(tooltipHideTimer);
|
||||
tooltipHideTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const onTooltipLeave = () => {
|
||||
tooltipState.value.show = false;
|
||||
};
|
||||
|
||||
defineExpose({ showPeerTooltip, hidePeerTooltip });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<div
|
||||
v-if="tooltipState.show"
|
||||
:style="{ top: tooltipState.y + 'px', left: tooltipState.x + 'px' }"
|
||||
class="fixed z-[9999] mt-1 -translate-x-1/2 transform"
|
||||
@mouseenter="onTooltipEnter"
|
||||
@mouseleave="onTooltipLeave"
|
||||
>
|
||||
<div
|
||||
class="w-auto min-w-[260px] max-w-[320px] rounded-lg border border-slate-200 bg-white p-4 text-left text-sm text-slate-600 shadow-2xl dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200"
|
||||
>
|
||||
<div
|
||||
class="absolute -top-2 left-1/2 h-4 w-4 -translate-x-1/2 rotate-45 transform border-l border-t border-slate-200 bg-white dark:border-slate-600 dark:bg-slate-800"
|
||||
></div>
|
||||
<div class="relative z-10 mb-2 flex items-center justify-between border-b border-slate-200 pb-2 dark:border-slate-600">
|
||||
<span class="text-xs font-bold uppercase text-slate-400">NAT Type</span>
|
||||
<span class="badge-green border border-green-200 dark:border-green-800">{{
|
||||
tooltipState.info.nat_type
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="tooltipState.info.public_ips && tooltipState.info.public_ips.length > 0"
|
||||
class="relative z-10 mb-3"
|
||||
>
|
||||
<span class="mb-1 block text-xs text-slate-400">Public IPv4:</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="pip in tooltipState.info.public_ips"
|
||||
:key="pip"
|
||||
class="rounded border border-slate-200 bg-slate-100 px-1.5 py-0.5 font-mono text-xs tabular-nums text-slate-600 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200"
|
||||
>{{ pip }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="tooltipState.info.ipv6" class="relative z-10">
|
||||
<span class="mb-1 block text-xs text-slate-400">IPv6:</span>
|
||||
<div
|
||||
class="whitespace-normal break-all rounded border border-slate-200 bg-slate-50 p-1.5 font-mono text-xs leading-relaxed text-slate-600 dark:border-slate-700/50 dark:bg-slate-900/50 dark:text-slate-200"
|
||||
>
|
||||
{{ tooltipState.info.ipv6 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup>
|
||||
import { useUiStore } from "../stores/ui";
|
||||
import AppModal from "./AppModal.vue";
|
||||
|
||||
const ui = useUiStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppModal
|
||||
:show="ui.confirmState.show"
|
||||
panel-class="w-full max-w-sm"
|
||||
@close="ui.confirmCancel"
|
||||
>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-bold text-slate-900 flex items-center gap-2 dark:text-white">
|
||||
<svg
|
||||
v-if="ui.confirmState.danger"
|
||||
class="w-5 h-5 text-red-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
{{ ui.confirmState.title }}
|
||||
</h3>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="px-6 py-5 text-sm text-slate-600 break-all dark:text-slate-300">
|
||||
{{ ui.confirmState.message }}
|
||||
</p>
|
||||
</template>
|
||||
<template #footer>
|
||||
<button class="btn-ghost" @click="ui.confirmCancel">取消</button>
|
||||
<button
|
||||
:class="ui.confirmState.danger ? 'btn-danger' : 'btn-primary'"
|
||||
@click="ui.confirmOk"
|
||||
>
|
||||
{{ ui.confirmState.confirmText }}
|
||||
</button>
|
||||
</template>
|
||||
</AppModal>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
text: { type: String, default: "暂无数据" },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card p-12 text-center text-slate-500 dark:text-slate-400">
|
||||
<svg
|
||||
class="w-12 h-12 mx-auto mb-3 text-slate-300 dark:text-slate-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
|
||||
/>
|
||||
</svg>
|
||||
<p>{{ text }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,165 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { useAppStore } from "../stores/app";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
|
||||
const props = defineProps({
|
||||
inst: { type: Object, required: true },
|
||||
// 是否显示选中高亮(实例页用)
|
||||
selectable: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const app = useAppStore();
|
||||
const ui = useUiStore();
|
||||
|
||||
const info = computed(() => app.infoOf(props.inst.file_name));
|
||||
const loading = computed(() => !!app.loadingMap[props.inst.file_name]);
|
||||
// 停止中:点击停止后直到实例真正消失/停止
|
||||
const stopping = computed(() => !!app.stoppingMap[props.inst.file_name]);
|
||||
// 展示名优先用配置名称,兜底文件名
|
||||
const displayName = computed(() => props.inst.config_name || props.inst.file_name);
|
||||
|
||||
const statusBadgeClass = (status) =>
|
||||
stopping.value
|
||||
? "badge-yellow"
|
||||
: status === "running"
|
||||
? "badge-green"
|
||||
: status === "starting"
|
||||
? "badge-blue"
|
||||
: "badge-gray";
|
||||
const statusText = (status) =>
|
||||
stopping.value
|
||||
? "停止中"
|
||||
: status === "running"
|
||||
? "运行中"
|
||||
: status === "starting"
|
||||
? "启动中"
|
||||
: "已停止";
|
||||
|
||||
const select = () => {
|
||||
if (props.selectable) app.selectedInstance = props.inst.file_name;
|
||||
};
|
||||
|
||||
const confirmStop = async () => {
|
||||
const ok = await ui.confirm({
|
||||
title: "停止组网",
|
||||
message: `确定要停止 ${displayName.value} 吗?`,
|
||||
danger: true,
|
||||
confirmText: "停止",
|
||||
});
|
||||
if (ok) app.stopVnt(props.inst.file_name);
|
||||
};
|
||||
|
||||
const confirmRestart = async () => {
|
||||
const ok = await ui.confirm({
|
||||
title: "重启组网",
|
||||
message: `确定要重启 ${displayName.value} 吗?`,
|
||||
});
|
||||
if (ok) app.restartVnt(props.inst.file_name);
|
||||
};
|
||||
|
||||
const confirmDismiss = async () => {
|
||||
const ok = await ui.confirm({
|
||||
title: "移除实例",
|
||||
message: `确定要移除已停止的实例 ${displayName.value} 吗?`,
|
||||
danger: true,
|
||||
confirmText: "移除",
|
||||
});
|
||||
if (ok) app.dismissInstance(props.inst.file_name);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="card"
|
||||
:class="[
|
||||
selectable ? 'cursor-pointer' : '',
|
||||
selectable && app.selectedInstance === inst.file_name
|
||||
? 'ring-2 ring-indigo-500 dark:ring-indigo-400'
|
||||
: '',
|
||||
]"
|
||||
@click="select"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h3 class="truncate text-base font-bold text-slate-900 dark:text-white" :title="inst.file_name">
|
||||
{{ displayName }}
|
||||
</h3>
|
||||
<div class="flex shrink-0 items-center gap-1.5">
|
||||
<span
|
||||
v-if="info.config_changed"
|
||||
class="badge-yellow"
|
||||
title="配置文件在启动后被修改,重启实例后生效"
|
||||
>
|
||||
配置发生变化
|
||||
</span>
|
||||
<span :class="[statusBadgeClass(inst.status), stopping ? 'animate-pulse' : '']">{{
|
||||
statusText(inst.status)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-sm">
|
||||
<span class="muted">虚拟 IP:</span>
|
||||
<span class="ml-1 font-mono tabular-nums text-indigo-600 dark:text-indigo-400">{{
|
||||
info.ip || "-"
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex gap-4 text-xs muted">
|
||||
<span>
|
||||
在线
|
||||
<span class="font-bold tabular-nums text-blue-600 dark:text-blue-400">{{
|
||||
info.online_client_num || 0
|
||||
}}</span>
|
||||
</span>
|
||||
<span>
|
||||
直连
|
||||
<span class="font-bold tabular-nums text-green-600 dark:text-green-400">{{
|
||||
info.direct_client_num || 0
|
||||
}}</span>
|
||||
</span>
|
||||
<span>
|
||||
离线
|
||||
<span class="font-bold tabular-nums text-slate-400">{{ info.offline_client_num || 0 }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap justify-end gap-2">
|
||||
<template v-if="stopping">
|
||||
<span class="flex items-center gap-1.5 text-xs muted">
|
||||
<span class="inline-block animate-spin">⟳</span>
|
||||
正在停止,请稍候...
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button
|
||||
v-if="inst.status === 'running' || inst.status === 'stopped'"
|
||||
class="btn-primary btn-sm"
|
||||
:disabled="loading"
|
||||
@click.stop="confirmRestart"
|
||||
>
|
||||
<span v-if="loading" class="animate-spin">⟳</span>
|
||||
{{ inst.status === "stopped" ? "重新启动" : "重启" }}
|
||||
</button>
|
||||
<button
|
||||
v-if="inst.status !== 'stopped'"
|
||||
class="btn-danger btn-sm"
|
||||
:disabled="loading"
|
||||
@click.stop="confirmStop"
|
||||
>
|
||||
<span v-if="loading" class="animate-spin">⟳</span>
|
||||
停止
|
||||
</button>
|
||||
<button
|
||||
v-if="inst.status === 'stopped'"
|
||||
class="btn-ghost btn-sm"
|
||||
:disabled="loading"
|
||||
@click.stop="confirmDismiss"
|
||||
>
|
||||
<span v-if="loading" class="animate-spin">⟳</span>
|
||||
移除
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,136 @@
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, nextTick } from "vue";
|
||||
import { formatSpeed, niceNumber } from "../utils/format";
|
||||
|
||||
const props = defineProps({
|
||||
history: { type: Object, required: true }, // { tx: [], rx: [] }
|
||||
size: { type: Number, default: 60 }, // 历史点数
|
||||
});
|
||||
|
||||
const canvasRef = ref(null);
|
||||
const maxLabel = ref("");
|
||||
|
||||
const draw = () => {
|
||||
const canvas = canvasRef.value;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
const txArr = props.history ? props.history.tx : [];
|
||||
const rxArr = props.history ? props.history.rx : [];
|
||||
const HISTORY_SIZE = props.size;
|
||||
|
||||
// 高清适配
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
if (rect.width === 0) return;
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const w = rect.width;
|
||||
const h = rect.height;
|
||||
const isDark = document.documentElement.classList.contains("dark");
|
||||
const colors = isDark
|
||||
? { bg: "#0c1222", grid: "rgba(71, 85, 105, 0.3)", rx: "#60a5fa", rxFill: "rgba(96, 165, 250, 0.15)", tx: "#4ade80", txFill: "rgba(74, 222, 128, 0.15)" }
|
||||
: { bg: "#f8fafc", grid: "rgba(148, 163, 184, 0.35)", rx: "#3b82f6", rxFill: "rgba(59, 130, 246, 0.12)", tx: "#22c55e", txFill: "rgba(34, 197, 94, 0.12)" };
|
||||
|
||||
const padTop = 8,
|
||||
padBottom = 4,
|
||||
padLeft = 0,
|
||||
padRight = 0;
|
||||
const chartW = w - padLeft - padRight;
|
||||
const chartH = h - padTop - padBottom;
|
||||
|
||||
// 背景
|
||||
ctx.fillStyle = colors.bg;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// 计算Y轴最大值
|
||||
const allValues = [...txArr, ...rxArr];
|
||||
let maxVal = allValues.length > 0 ? Math.max(...allValues) : 0;
|
||||
if (maxVal < 1024) maxVal = 1024; // 最小1KB
|
||||
const niceMax = niceNumber(maxVal);
|
||||
|
||||
maxLabel.value = "峰值: " + formatSpeed(niceMax);
|
||||
|
||||
// 网格线
|
||||
const gridLines = 4;
|
||||
ctx.strokeStyle = colors.grid;
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i <= gridLines; i++) {
|
||||
const y = padTop + (chartH / gridLines) * i;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padLeft, y);
|
||||
ctx.lineTo(padLeft + chartW, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
// 垂直网格线
|
||||
const vLines = 6;
|
||||
for (let i = 0; i <= vLines; i++) {
|
||||
const x = padLeft + (chartW / vLines) * i;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, padTop);
|
||||
ctx.lineTo(x, padTop + chartH);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 绘制曲线
|
||||
const drawLine = (data, strokeColor, fillColor) => {
|
||||
if (data.length < 2) return;
|
||||
const step = chartW / (HISTORY_SIZE - 1);
|
||||
const offset = HISTORY_SIZE - data.length;
|
||||
|
||||
// 填充区域
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padLeft + offset * step, padTop + chartH);
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const x = padLeft + (offset + i) * step;
|
||||
const y = padTop + chartH - (data[i] / niceMax) * chartH;
|
||||
if (i === 0) ctx.lineTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.lineTo(padLeft + (offset + data.length - 1) * step, padTop + chartH);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = fillColor;
|
||||
ctx.fill();
|
||||
|
||||
// 线条
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const x = padLeft + (offset + i) * step;
|
||||
const y = padTop + chartH - (data[i] / niceMax) * chartH;
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.strokeStyle = strokeColor;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
drawLine(rxArr, colors.rx, colors.rxFill);
|
||||
drawLine(txArr, colors.tx, colors.txFill);
|
||||
};
|
||||
|
||||
onMounted(() => nextTick(draw));
|
||||
|
||||
// 历史数据更新时重绘(数组原地 push/shift,监听引用内每个点)
|
||||
watch(
|
||||
() => [props.history?.tx?.length, props.history?.rx?.length, props.history?.tx?.at(-1), props.history?.rx?.at(-1)],
|
||||
() => nextTick(draw),
|
||||
);
|
||||
|
||||
defineExpose({ draw });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center gap-4 mb-2 text-xs text-slate-400">
|
||||
<span class="flex items-center"
|
||||
><span class="inline-block w-3 h-0.5 bg-green-400 mr-1"></span>上传速度</span
|
||||
>
|
||||
<span class="flex items-center"
|
||||
><span class="inline-block w-3 h-0.5 bg-blue-400 mr-1"></span>下载速度</span
|
||||
>
|
||||
<span class="ml-auto">{{ maxLabel }}</span>
|
||||
</div>
|
||||
<canvas ref="canvasRef" class="rounded w-full block h-[150px]"></canvas>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { useAppStore } from "../stores/app";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
import AppSelect from "./AppSelect.vue";
|
||||
|
||||
// 启动组网面板:选择配置 + 启动,总览页与实例页复用
|
||||
const app = useAppStore();
|
||||
const ui = useUiStore();
|
||||
|
||||
const localSelectedConfig = ref("");
|
||||
|
||||
// 只列出没有对应实例的配置(同一配置最多一个实例)
|
||||
const availableConfigs = computed(() =>
|
||||
app.configList.filter(
|
||||
(cfg) => !app.instanceList.some((inst) => inst.file_name === cfg.file_name),
|
||||
),
|
||||
);
|
||||
const configOptions = computed(() =>
|
||||
availableConfigs.value.map((cfg) => ({
|
||||
value: cfg.file_name,
|
||||
label: cfg.config_name || cfg.file_name,
|
||||
})),
|
||||
);
|
||||
|
||||
// 默认选中第一个可用配置;当前选中项不可用时(如已启动)自动切到下一个
|
||||
watch(
|
||||
availableConfigs,
|
||||
(list) => {
|
||||
if (!list.some((cfg) => cfg.file_name === localSelectedConfig.value)) {
|
||||
localSelectedConfig.value = list.length ? list[0].file_name : "";
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const handleStart = () => {
|
||||
if (!localSelectedConfig.value) {
|
||||
ui.toast.error("请先选择一个配置");
|
||||
return;
|
||||
}
|
||||
app.startVnt(localSelectedConfig.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<h2 class="mb-4 text-base font-bold text-slate-900 dark:text-white">启动组网</h2>
|
||||
|
||||
<div v-if="app.configList.length === 0" class="flex flex-wrap items-center justify-between gap-3">
|
||||
<p class="text-sm muted">还没有任何配置,先创建一个组网配置吧。</p>
|
||||
<router-link to="/config" class="btn-primary btn-sm">去新建配置</router-link>
|
||||
</div>
|
||||
|
||||
<div v-else-if="availableConfigs.length === 0" class="text-sm muted">
|
||||
所有配置均已启动。
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div class="flex-1">
|
||||
<label class="mb-1.5 block text-xs font-medium muted">选择配置</label>
|
||||
<AppSelect v-model="localSelectedConfig" :options="configOptions" placeholder="请选择配置…" aria-label="选择配置" />
|
||||
</div>
|
||||
<button
|
||||
class="btn-primary px-8"
|
||||
:disabled="!localSelectedConfig || !!app.loadingMap[localSelectedConfig]"
|
||||
@click="handleStart"
|
||||
>
|
||||
<span v-if="app.loadingMap[localSelectedConfig]" class="animate-spin">⟳</span>
|
||||
启动
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
status: { type: String, default: "stopped" },
|
||||
size: { type: String, default: "w-2.5 h-2.5" },
|
||||
});
|
||||
|
||||
const dotClass = (status) =>
|
||||
status === "running"
|
||||
? "bg-green-500"
|
||||
: status === "starting"
|
||||
? "bg-blue-500 animate-pulse"
|
||||
: "bg-slate-400 dark:bg-slate-500";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="rounded-full inline-block shrink-0" :class="[size, dotClass(status)]" />
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup>
|
||||
import { useUiStore } from "../stores/ui";
|
||||
|
||||
const ui = useUiStore();
|
||||
|
||||
// 浅色卡片 + 左侧色条
|
||||
const barClass = (type) =>
|
||||
type === "success" ? "bg-green-500" : type === "error" ? "bg-red-500" : "bg-indigo-500";
|
||||
|
||||
const iconClass = (type) =>
|
||||
type === "success"
|
||||
? "text-green-500"
|
||||
: type === "error"
|
||||
? "text-red-500"
|
||||
: "text-indigo-500";
|
||||
|
||||
const iconPath = (type) =>
|
||||
type === "success"
|
||||
? "M5 13l4 4L19 7"
|
||||
: type === "error"
|
||||
? "M6 18L18 6M6 6l12 12"
|
||||
: "M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<div class="pointer-events-none fixed right-4 top-4 z-[100] flex flex-col items-end gap-2">
|
||||
<transition-group name="toast">
|
||||
<div
|
||||
v-for="t in ui.toasts"
|
||||
:key="t.id"
|
||||
class="pointer-events-auto flex max-w-sm items-stretch overflow-hidden rounded-lg border border-slate-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-800"
|
||||
>
|
||||
<span class="w-1 shrink-0" :class="barClass(t.type)"></span>
|
||||
<div class="flex items-center gap-2 px-4 py-2.5">
|
||||
<svg class="h-4 w-4 shrink-0" :class="iconClass(t.type)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="iconPath(t.type)" />
|
||||
</svg>
|
||||
<span class="break-all text-sm text-slate-700 dark:text-slate-200">{{ t.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</transition-group>
|
||||
</div>
|
||||
</teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import vntIcon from "./assets/vnt-icon.png";
|
||||
import "./style.css";
|
||||
|
||||
const favicon = document.querySelector('link[rel~="icon"]') || document.createElement("link");
|
||||
favicon.rel = "icon";
|
||||
favicon.type = "image/png";
|
||||
favicon.href = vntIcon;
|
||||
document.head.appendChild(favicon);
|
||||
|
||||
createApp(App).use(createPinia()).use(router).mount("#app");
|
||||
@@ -0,0 +1,48 @@
|
||||
export const navItems = [
|
||||
{
|
||||
to: "/",
|
||||
label: "网络总览",
|
||||
shortLabel: "总览",
|
||||
subtitle: "组网状态与实例管理",
|
||||
icon: "M4 4h6v6H4V4Zm10 0h6v6h-6V4ZM4 14h6v6H4v-6Zm10 0h6v6h-6v-6Z",
|
||||
},
|
||||
{
|
||||
to: "/peers",
|
||||
label: "在线设备",
|
||||
shortLabel: "设备",
|
||||
subtitle: "查看网络中的对端设备",
|
||||
icon: "M15 19a6 6 0 0 0-12 0m18 0a5 5 0 0 0-5-5m-7-3a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm8 0a3 3 0 1 0 0-6",
|
||||
},
|
||||
{
|
||||
to: "/routes",
|
||||
label: "路由表",
|
||||
shortLabel: "路由",
|
||||
subtitle: "虚拟网络路由信息",
|
||||
icon: "M6 8a2 2 0 1 0 0-4 2 2 0 0 0 0 4Zm12 12a2 2 0 1 0 0-4 2 2 0 0 0 0 4ZM8 6h6a4 4 0 0 1 4 4v2m-3-3 3 3 3-3M16 18h-6a4 4 0 0 1-4-4v-2",
|
||||
},
|
||||
{
|
||||
to: "/config",
|
||||
label: "组网配置",
|
||||
shortLabel: "配置",
|
||||
subtitle: "管理组网配置文件",
|
||||
icon: "M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm7.4-.5a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3A1.7 1.7 0 0 0 14 21v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14h-.2v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1A1.7 1.7 0 0 0 9 4.6 1.7 1.7 0 0 0 10 3v-.2h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6-1Z",
|
||||
},
|
||||
{
|
||||
to: "/web-access",
|
||||
label: "Web 访问",
|
||||
shortLabel: "Web",
|
||||
subtitle: "远程访问设置",
|
||||
desktopOnly: true,
|
||||
icon: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0 0c-2.2-2.5 3.3-5.5 3.3-9S14.2 5.5 12 3m0 18c-2.2-2.5-3.3-5.5-3.3-9S9.8 5.5 12 3M3.5 9h17m-17 6h17",
|
||||
},
|
||||
{
|
||||
to: "/about",
|
||||
label: "关于",
|
||||
shortLabel: "关于",
|
||||
subtitle: "版本与更新",
|
||||
icon: "M12 17v-6m0-4h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z",
|
||||
},
|
||||
];
|
||||
|
||||
export const visibleNavItems = () =>
|
||||
navItems.filter((item) => !item.desktopOnly || globalThis.__VNT_DESKTOP__);
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createRouter, createWebHashHistory } from "vue-router";
|
||||
import DashboardView from "../views/DashboardView.vue";
|
||||
import ConfigView from "../views/ConfigView.vue";
|
||||
import PeersView from "../views/PeersView.vue";
|
||||
import RoutesView from "../views/RoutesView.vue";
|
||||
import WebAccessView from "../views/WebAccessView.vue";
|
||||
import AboutView from "../views/AboutView.vue";
|
||||
|
||||
const routes = [
|
||||
{ path: "/", component: DashboardView },
|
||||
// 兼容旧路由:实例管理已并入网络总览
|
||||
{ path: "/instances", redirect: "/" },
|
||||
{ path: "/general", redirect: "/" },
|
||||
{ path: "/config", component: ConfigView },
|
||||
{ path: "/peers", component: PeersView },
|
||||
{ path: "/routes", component: RoutesView },
|
||||
{ path: "/web-access", component: WebAccessView },
|
||||
{ path: "/about", component: AboutView },
|
||||
];
|
||||
|
||||
export default createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref, computed, onMounted, onUnmounted, getCurrentInstance } from "vue";
|
||||
import {
|
||||
getInstances,
|
||||
getInstanceInfo,
|
||||
getConfigList,
|
||||
getVersion,
|
||||
startVntApi,
|
||||
stopVntApi,
|
||||
restartVntApi,
|
||||
deleteInstance,
|
||||
} from "../api";
|
||||
import { useUiStore } from "./ui";
|
||||
import { useStartLogStore } from "./startLog";
|
||||
import { getAccessToken, isDesktop } from "../auth";
|
||||
|
||||
export const useAppStore = defineStore("app", () => {
|
||||
const ui = useUiStore();
|
||||
const startLog = useStartLogStore();
|
||||
|
||||
// 多实例状态:key=file_name, value=该实例 info
|
||||
const instances = ref({});
|
||||
const instanceList = ref([]);
|
||||
const selectedInstance = ref(null);
|
||||
const configList = ref([]);
|
||||
const loadingMap = ref({});
|
||||
// 停止中的实例:key=file_name;点击停止后置位,实例从列表消失或变为 stopped 时清除
|
||||
const stoppingMap = ref({});
|
||||
|
||||
// 页面可见性
|
||||
const isPageVisible = ref(!document.hidden);
|
||||
const visibilityHandler = () => {
|
||||
isPageVisible.value = !document.hidden;
|
||||
};
|
||||
|
||||
let infoTimer = null;
|
||||
|
||||
const runningCount = computed(
|
||||
() => instanceList.value.filter((i) => i.status === "running").length,
|
||||
);
|
||||
const startingCount = computed(
|
||||
() => instanceList.value.filter((i) => i.status === "starting").length,
|
||||
);
|
||||
const headerStatusText = computed(() => {
|
||||
if (runningCount.value > 0) return `运行中 x${runningCount.value}`;
|
||||
if (startingCount.value > 0) return "启动中...";
|
||||
return "未启动";
|
||||
});
|
||||
const selectedInfo = computed(() =>
|
||||
selectedInstance.value ? instances.value[selectedInstance.value] || null : null,
|
||||
);
|
||||
const selectedConfigName = computed(() => {
|
||||
if (!selectedInstance.value) return "";
|
||||
const inst = instanceList.value.find(
|
||||
(i) => i.file_name === selectedInstance.value,
|
||||
);
|
||||
return inst ? inst.config_name || inst.file_name : selectedInstance.value;
|
||||
});
|
||||
// 客户端版本号,来自 /api/version,与组网状态无关
|
||||
const version = ref("");
|
||||
|
||||
const fetchVersion = async () => {
|
||||
try {
|
||||
version.value = (await getVersion()) || "";
|
||||
} catch (e) {
|
||||
console.error("Fetch version error", e);
|
||||
}
|
||||
};
|
||||
const isServerConnected = computed(
|
||||
() =>
|
||||
!!(
|
||||
selectedInfo.value &&
|
||||
selectedInfo.value.server_info &&
|
||||
selectedInfo.value.server_info.some((s) => s.connected)
|
||||
),
|
||||
);
|
||||
const serverStatusText = computed(() => {
|
||||
const si = selectedInfo.value;
|
||||
if (!si || !si.server_info || !si.server_info.length) return "未配置服务器";
|
||||
return `${si.server_info.filter((s) => s.connected).length} / ${si.server_info.length} 已连接`;
|
||||
});
|
||||
|
||||
const infoOf = (fileName) => instances.value[fileName] || {};
|
||||
|
||||
const fetchInstanceInfo = async (fileName) => {
|
||||
try {
|
||||
instances.value[fileName] = await getInstanceInfo(fileName);
|
||||
} catch (e) {
|
||||
console.error("Fetch info error", e);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchInstances = async () => {
|
||||
try {
|
||||
const list = (await getInstances()) || [];
|
||||
instanceList.value = list;
|
||||
// 清理已消失实例的 info 缓存
|
||||
for (const key of Object.keys(instances.value)) {
|
||||
if (!list.some((i) => i.file_name === key)) {
|
||||
delete instances.value[key];
|
||||
}
|
||||
}
|
||||
// 停止已生效(实例消失或进入 stopped)时清除停止中标记
|
||||
for (const key of Object.keys(stoppingMap.value)) {
|
||||
const inst = list.find((i) => i.file_name === key);
|
||||
if (!inst || inst.status === "stopped") {
|
||||
delete stoppingMap.value[key];
|
||||
}
|
||||
}
|
||||
// 默认选中逻辑:当前选中失效时优先第一个 running,否则第一个,否则 null
|
||||
if (
|
||||
!selectedInstance.value ||
|
||||
!list.some((i) => i.file_name === selectedInstance.value)
|
||||
) {
|
||||
const running = list.find((i) => i.status === "running");
|
||||
selectedInstance.value = running
|
||||
? running.file_name
|
||||
: list.length > 0
|
||||
? list[0].file_name
|
||||
: null;
|
||||
}
|
||||
// 拉取 running 实例的详情
|
||||
for (const inst of list) {
|
||||
if (inst.status === "running") {
|
||||
fetchInstanceInfo(inst.file_name);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Fetch instances error", e);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchConfigList = async () => {
|
||||
try {
|
||||
configList.value = (await getConfigList()) || [];
|
||||
} catch (e) {
|
||||
console.error("Fetch list error", e);
|
||||
}
|
||||
};
|
||||
|
||||
const startVnt = async (fileName) => {
|
||||
if (!fileName) {
|
||||
ui.toast.error("请先选择一个配置");
|
||||
return;
|
||||
}
|
||||
if (loadingMap.value[fileName]) return;
|
||||
loadingMap.value[fileName] = true;
|
||||
try {
|
||||
await startVntApi(fileName);
|
||||
startLog.openStartLog(fileName);
|
||||
fetchInstances();
|
||||
} catch (e) {
|
||||
ui.toast.error("启动失败: " + e.message);
|
||||
} finally {
|
||||
loadingMap.value[fileName] = false;
|
||||
}
|
||||
};
|
||||
|
||||
const stopVnt = async (fileName) => {
|
||||
if (!fileName || loadingMap.value[fileName]) return;
|
||||
loadingMap.value[fileName] = true;
|
||||
stoppingMap.value[fileName] = true;
|
||||
try {
|
||||
await stopVntApi(fileName);
|
||||
ui.toast.success("已停止");
|
||||
if (startLog.logFileName === fileName) {
|
||||
startLog.stopPolling();
|
||||
startLog.showStartLog = false;
|
||||
}
|
||||
fetchInstances();
|
||||
} catch (e) {
|
||||
ui.toast.error("停止失败: " + e.message);
|
||||
console.error(e);
|
||||
// 停止失败,恢复可操作状态
|
||||
delete stoppingMap.value[fileName];
|
||||
} finally {
|
||||
loadingMap.value[fileName] = false;
|
||||
}
|
||||
};
|
||||
|
||||
const restartVnt = async (fileName) => {
|
||||
if (!fileName || loadingMap.value[fileName]) return;
|
||||
loadingMap.value[fileName] = true;
|
||||
try {
|
||||
await restartVntApi(fileName);
|
||||
startLog.openStartLog(fileName);
|
||||
fetchInstances();
|
||||
} catch (e) {
|
||||
ui.toast.error("重启失败: " + e.message);
|
||||
} finally {
|
||||
loadingMap.value[fileName] = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 移除已停止(启动失败残留)的实例条目
|
||||
const dismissInstance = async (fileName) => {
|
||||
if (!fileName || loadingMap.value[fileName]) return;
|
||||
loadingMap.value[fileName] = true;
|
||||
try {
|
||||
await deleteInstance(fileName);
|
||||
ui.toast.success("已移除");
|
||||
if (startLog.logFileName === fileName) {
|
||||
startLog.stopPolling();
|
||||
startLog.showStartLog = false;
|
||||
}
|
||||
if (selectedInstance.value === fileName) {
|
||||
selectedInstance.value = null;
|
||||
}
|
||||
fetchInstances();
|
||||
} catch (e) {
|
||||
ui.toast.error("移除失败: " + e.message);
|
||||
} finally {
|
||||
loadingMap.value[fileName] = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 注入依赖,供启动日志 store 刷新实例与解析配置名
|
||||
startLog.bindApp({ fetchInstances, instanceList, configList });
|
||||
|
||||
const init = async () => {
|
||||
if (!isDesktop && !getAccessToken()) return;
|
||||
document.addEventListener("visibilitychange", visibilityHandler);
|
||||
fetchVersion();
|
||||
await fetchInstances();
|
||||
fetchConfigList();
|
||||
// 页面加载时若有正在启动的实例,恢复其日志弹窗
|
||||
const starting = instanceList.value.find((i) => i.status === "starting");
|
||||
if (starting) startLog.openStartLog(starting.file_name);
|
||||
// 全局 3s 轮询实例列表及 running 实例详情(仅页面可见)
|
||||
infoTimer = setInterval(() => {
|
||||
if (isPageVisible.value) fetchInstances();
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const destroy = () => {
|
||||
document.removeEventListener("visibilitychange", visibilityHandler);
|
||||
startLog.stopPolling();
|
||||
if (infoTimer) clearInterval(infoTimer);
|
||||
};
|
||||
|
||||
// store 在组件 setup 中被使用时自动挂接生命周期
|
||||
if (getCurrentInstance()) {
|
||||
onMounted(init);
|
||||
onUnmounted(destroy);
|
||||
}
|
||||
|
||||
return {
|
||||
instances,
|
||||
instanceList,
|
||||
selectedInstance,
|
||||
configList,
|
||||
loadingMap,
|
||||
stoppingMap,
|
||||
isPageVisible,
|
||||
runningCount,
|
||||
startingCount,
|
||||
headerStatusText,
|
||||
selectedInfo,
|
||||
selectedConfigName,
|
||||
version,
|
||||
isServerConnected,
|
||||
serverStatusText,
|
||||
infoOf,
|
||||
fetchInstances,
|
||||
fetchInstanceInfo,
|
||||
fetchConfigList,
|
||||
startVnt,
|
||||
stopVnt,
|
||||
restartVnt,
|
||||
dismissInstance,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref, computed, nextTick } from "vue";
|
||||
import { getStartStatus, stopVntApi } from "../api";
|
||||
|
||||
// 启动日志弹窗状态
|
||||
export const useStartLogStore = defineStore("startLog", () => {
|
||||
const showStartLog = ref(false);
|
||||
const startLogs = ref([]);
|
||||
const startStatus = ref("stopped");
|
||||
const logFileName = ref(null);
|
||||
const logContainer = ref(null);
|
||||
let statusInterval = null;
|
||||
|
||||
// 由 app store 注入,避免循环依赖
|
||||
let fetchInstancesFn = null;
|
||||
let instanceListRef = null;
|
||||
let configListRef = null;
|
||||
const bindApp = ({ fetchInstances, instanceList, configList }) => {
|
||||
fetchInstancesFn = fetchInstances;
|
||||
instanceListRef = instanceList;
|
||||
configListRef = configList;
|
||||
};
|
||||
|
||||
const logConfigName = computed(() => {
|
||||
if (!logFileName.value) return "";
|
||||
const inst = (instanceListRef?.value || []).find(
|
||||
(i) => i.file_name === logFileName.value,
|
||||
);
|
||||
if (inst) return inst.config_name || inst.file_name;
|
||||
const cfg = (configListRef?.value || []).find(
|
||||
(c) => c.file_name === logFileName.value,
|
||||
);
|
||||
return cfg ? cfg.config_name || cfg.file_name : logFileName.value;
|
||||
});
|
||||
|
||||
const pollStartStatus = async () => {
|
||||
if (!logFileName.value) return;
|
||||
try {
|
||||
const data = await getStartStatus(logFileName.value);
|
||||
startLogs.value = data.logs || [];
|
||||
startStatus.value = data.status;
|
||||
nextTick(() => {
|
||||
if (logContainer.value)
|
||||
logContainer.value.scrollTop = logContainer.value.scrollHeight;
|
||||
});
|
||||
|
||||
if (startStatus.value === "running") {
|
||||
stopPolling();
|
||||
fetchInstancesFn && fetchInstancesFn();
|
||||
showStartLog.value = false;
|
||||
} else if (startStatus.value === "stopped" && startLogs.value.length > 0) {
|
||||
stopPolling();
|
||||
fetchInstancesFn && fetchInstancesFn();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const stopPolling = () => {
|
||||
if (statusInterval) {
|
||||
clearInterval(statusInterval);
|
||||
statusInterval = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startPolling = () => {
|
||||
stopPolling();
|
||||
statusInterval = setInterval(pollStartStatus, 1000);
|
||||
pollStartStatus();
|
||||
};
|
||||
|
||||
const openStartLog = (fileName) => {
|
||||
logFileName.value = fileName;
|
||||
startLogs.value = [];
|
||||
startStatus.value = "starting";
|
||||
showStartLog.value = true;
|
||||
startPolling();
|
||||
};
|
||||
|
||||
// 取消组网:停止轮询并 POST /api/stop
|
||||
const cancelStart = async () => {
|
||||
stopPolling();
|
||||
const fileName = logFileName.value;
|
||||
if (fileName) {
|
||||
try {
|
||||
await stopVntApi(fileName);
|
||||
startLogs.value.push("启动已手动取消");
|
||||
} catch (e) {
|
||||
// 忽略取消时的网络错误
|
||||
}
|
||||
}
|
||||
startStatus.value = "stopped";
|
||||
fetchInstancesFn && fetchInstancesFn();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
showStartLog.value = false;
|
||||
};
|
||||
|
||||
return {
|
||||
showStartLog,
|
||||
startLogs,
|
||||
startStatus,
|
||||
logFileName,
|
||||
logContainer,
|
||||
logConfigName,
|
||||
bindApp,
|
||||
openStartLog,
|
||||
pollStartStatus,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
cancelStart,
|
||||
close,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { reactive, ref } from "vue";
|
||||
|
||||
let toastId = 0;
|
||||
|
||||
export const useUiStore = defineStore("ui", () => {
|
||||
// toast 队列
|
||||
const toasts = ref([]);
|
||||
|
||||
const pushToast = (type, message, duration = 3000) => {
|
||||
const id = ++toastId;
|
||||
toasts.value.push({ id, type, message });
|
||||
setTimeout(() => {
|
||||
toasts.value = toasts.value.filter((t) => t.id !== id);
|
||||
}, duration);
|
||||
};
|
||||
|
||||
const toast = {
|
||||
success: (msg) => pushToast("success", msg),
|
||||
error: (msg) => pushToast("error", msg, 4500),
|
||||
info: (msg) => pushToast("info", msg),
|
||||
};
|
||||
|
||||
// confirm 弹窗:返回 Promise<boolean>
|
||||
const confirmState = reactive({
|
||||
show: false,
|
||||
title: "",
|
||||
message: "",
|
||||
danger: false,
|
||||
confirmText: "确定",
|
||||
resolve: null,
|
||||
});
|
||||
|
||||
const confirm = ({ title = "确认操作", message = "", danger = false, confirmText = "确定" } = {}) =>
|
||||
new Promise((resolve) => {
|
||||
confirmState.show = true;
|
||||
confirmState.title = title;
|
||||
confirmState.message = message;
|
||||
confirmState.danger = danger;
|
||||
confirmState.confirmText = confirmText;
|
||||
confirmState.resolve = resolve;
|
||||
});
|
||||
|
||||
const confirmOk = () => {
|
||||
confirmState.show = false;
|
||||
confirmState.resolve && confirmState.resolve(true);
|
||||
confirmState.resolve = null;
|
||||
};
|
||||
|
||||
const confirmCancel = () => {
|
||||
confirmState.show = false;
|
||||
confirmState.resolve && confirmState.resolve(false);
|
||||
confirmState.resolve = null;
|
||||
};
|
||||
|
||||
return {
|
||||
toasts,
|
||||
toast,
|
||||
confirmState,
|
||||
confirm,
|
||||
confirmOk,
|
||||
confirmCancel,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
@import "tailwindcss";
|
||||
@source "./**/*.{vue,js}";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
/* 全局唯一主色 */
|
||||
--color-accent: var(--color-indigo-600);
|
||||
--color-accent-hover: var(--color-indigo-500);
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-slate-50 text-slate-700 antialiased dark:bg-slate-950 dark:text-slate-200;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
@apply rounded bg-slate-300 dark:bg-slate-600;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-slate-400 dark:bg-slate-500;
|
||||
}
|
||||
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-slate-300) transparent;
|
||||
}
|
||||
|
||||
.dark .custom-scrollbar {
|
||||
scrollbar-color: var(--color-slate-600) transparent;
|
||||
}
|
||||
|
||||
/* 路由切换动画 */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 实例卡片列表:停止/移除后渐隐收缩消失 */
|
||||
.card-list-enter-active,
|
||||
.card-list-leave-active {
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.3s ease;
|
||||
}
|
||||
|
||||
.card-list-enter-from,
|
||||
.card-list-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
/* 弹窗动画 */
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-enter-active .modal-panel,
|
||||
.modal-leave-active .modal-panel {
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-enter-from .modal-panel,
|
||||
.modal-leave-to .modal-panel {
|
||||
transform: scale(0.96);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Toast 动画 */
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||
}
|
||||
|
||||
.toast-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
/* 移动导航动画 */
|
||||
.navdrop-enter-active,
|
||||
.navdrop-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.navdrop-enter-from,
|
||||
.navdrop-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
|
||||
/* 移动端侧栏抽屉 */
|
||||
.drawer-enter-active,
|
||||
.drawer-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.drawer-enter-active .drawer-panel,
|
||||
.drawer-leave-active .drawer-panel {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.drawer-enter-from,
|
||||
.drawer-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.drawer-enter-from .drawer-panel,
|
||||
.drawer-leave-to .drawer-panel {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
@utility btn {
|
||||
@apply inline-flex items-center justify-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium
|
||||
transition-all duration-150 active:scale-95 cursor-pointer
|
||||
disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/40;
|
||||
}
|
||||
|
||||
@utility badge {
|
||||
@apply inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
@apply btn bg-indigo-600 text-white shadow-sm hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply btn border border-slate-300 bg-white text-slate-700 shadow-sm hover:bg-slate-50
|
||||
dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply btn bg-red-600 text-white shadow-sm hover:bg-red-500;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
@apply px-3 py-1.5 text-xs;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply rounded-xl border border-slate-200 bg-white p-5 shadow-sm transition-shadow duration-200
|
||||
hover:shadow-md dark:border-slate-700/80 dark:bg-slate-900;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900
|
||||
placeholder-slate-400 transition-colors
|
||||
focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/25
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
dark:border-slate-600 dark:bg-slate-800 dark:text-white dark:placeholder-slate-500;
|
||||
}
|
||||
|
||||
/* 语义徽章:浅色底深色字,dark 下深底浅字 */
|
||||
.badge-green {
|
||||
@apply badge bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300;
|
||||
}
|
||||
|
||||
.badge-red {
|
||||
@apply badge bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300;
|
||||
}
|
||||
|
||||
.badge-yellow {
|
||||
@apply badge bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300;
|
||||
}
|
||||
|
||||
.badge-blue {
|
||||
@apply badge bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300;
|
||||
}
|
||||
|
||||
.badge-purple {
|
||||
@apply badge bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300;
|
||||
}
|
||||
|
||||
.badge-gray {
|
||||
@apply badge bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-300;
|
||||
}
|
||||
|
||||
.table {
|
||||
@apply min-w-full divide-y divide-slate-200 dark:divide-slate-700;
|
||||
}
|
||||
|
||||
.table thead {
|
||||
@apply bg-slate-50 dark:bg-slate-800/60;
|
||||
}
|
||||
|
||||
.table thead th {
|
||||
@apply px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-500 whitespace-nowrap
|
||||
dark:text-slate-400;
|
||||
}
|
||||
|
||||
.table tbody {
|
||||
@apply divide-y divide-slate-200 bg-white dark:divide-slate-700 dark:bg-transparent;
|
||||
}
|
||||
|
||||
.table tbody td {
|
||||
@apply px-4 py-3 text-sm text-slate-600 whitespace-nowrap dark:text-slate-300;
|
||||
}
|
||||
|
||||
/* 表格内静态 tooltip */
|
||||
.tooltip {
|
||||
@apply relative inline-flex items-center justify-center;
|
||||
}
|
||||
|
||||
.tooltip .tooltip-text {
|
||||
@apply invisible absolute bottom-[125%] left-1/2 z-10 w-36 -translate-x-1/2 rounded-md
|
||||
border border-slate-200 bg-white px-2 py-1.5 text-center text-xs text-slate-600 shadow-lg
|
||||
opacity-0 transition-opacity duration-300 pointer-events-none
|
||||
dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200;
|
||||
}
|
||||
|
||||
.tooltip:hover .tooltip-text {
|
||||
@apply visible opacity-100;
|
||||
}
|
||||
|
||||
/* 页面标题区 */
|
||||
.page-title {
|
||||
@apply text-2xl font-bold text-slate-900 dark:text-white;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
@apply mt-1 text-sm text-slate-500 dark:text-slate-400;
|
||||
}
|
||||
|
||||
/* 页面名称由统一外壳标题栏展示,视图只保留自身操作按钮。 */
|
||||
.page-title,
|
||||
.page-subtitle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 弱文本 */
|
||||
.muted {
|
||||
@apply text-slate-500 dark:text-slate-400;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// 格式化时间
|
||||
export const formatTime = (timestamp) => {
|
||||
if (!timestamp) return "-";
|
||||
const date = new Date(timestamp * 1000);
|
||||
const pad = (n) => (n < 10 ? "0" + n : n);
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
};
|
||||
|
||||
// 格式化字节数
|
||||
export const formatBytes = (bytes) => {
|
||||
if (bytes === 0 || bytes === undefined || bytes === null) return "0B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let i = 0;
|
||||
let value = bytes;
|
||||
while (value >= 1024 && i < units.length - 1) {
|
||||
value /= 1024;
|
||||
i++;
|
||||
}
|
||||
return i === 0 ? value + units[i] : value.toFixed(2) + units[i];
|
||||
};
|
||||
|
||||
// 格式化速度(字节/秒)
|
||||
export const formatSpeed = (bytesPerSecond) => {
|
||||
if (
|
||||
bytesPerSecond === 0 ||
|
||||
bytesPerSecond === undefined ||
|
||||
bytesPerSecond === null
|
||||
)
|
||||
return "0B/s";
|
||||
const units = ["B/s", "KB/s", "MB/s", "GB/s"];
|
||||
let i = 0;
|
||||
let value = bytesPerSecond;
|
||||
while (value >= 1024 && i < units.length - 1) {
|
||||
value /= 1024;
|
||||
i++;
|
||||
}
|
||||
return i === 0 ? value + units[i] : value.toFixed(2) + units[i];
|
||||
};
|
||||
|
||||
// 将数值取整到适合的刻度
|
||||
export const niceNumber = (val) => {
|
||||
const units = [
|
||||
1024, // 1KB
|
||||
10 * 1024, // 10KB
|
||||
100 * 1024, // 100KB
|
||||
1024 * 1024, // 1MB
|
||||
10 * 1024 * 1024, // 10MB
|
||||
100 * 1024 * 1024, // 100MB
|
||||
1024 * 1024 * 1024, // 1GB
|
||||
];
|
||||
for (const u of units) {
|
||||
if (val <= u) return u;
|
||||
}
|
||||
return Math.ceil(val / (1024 * 1024 * 1024)) * 1024 * 1024 * 1024;
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
export const NETWORK_QR_TYPE = "vnt-network";
|
||||
export const NETWORK_QR_VERSION = 1;
|
||||
|
||||
export const buildNetworkQrPayload = (form) => {
|
||||
const networkCode = String(form.network_code || "").trim();
|
||||
const servers = (form.server || []).map((server) => String(server).trim()).filter(Boolean);
|
||||
const mtu = Number(form.mtu || 1380);
|
||||
|
||||
if (!networkCode) throw new Error("组网编号不能为空");
|
||||
if (servers.length === 0) throw new Error("服务器地址不能为空");
|
||||
if (!Number.isInteger(mtu) || mtu < 576 || mtu > 9000) {
|
||||
throw new Error("MTU 必须在 576-9000 之间");
|
||||
}
|
||||
|
||||
return {
|
||||
type: NETWORK_QR_TYPE,
|
||||
version: NETWORK_QR_VERSION,
|
||||
network_code: networkCode,
|
||||
server: servers,
|
||||
mtu,
|
||||
password: String(form.password || ""),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,365 @@
|
||||
// 从旧 index.html 原样迁移的 TOML <-> 表单双向解析逻辑
|
||||
|
||||
export const emptyFormData = () => ({
|
||||
config_name: "",
|
||||
network_code: "",
|
||||
server: [""],
|
||||
ip: "",
|
||||
mtu: null,
|
||||
rtx: false,
|
||||
fec: false,
|
||||
compress: false,
|
||||
no_punch: false,
|
||||
input: [],
|
||||
output: [],
|
||||
no_nat: false,
|
||||
device_mode: "tun",
|
||||
port_mapping: [],
|
||||
allow_mapping: false,
|
||||
device_name: "",
|
||||
device_id: "",
|
||||
tun_name: "",
|
||||
outbound_interface: "",
|
||||
password: "",
|
||||
cert_mode: "skip",
|
||||
fingerprint: "",
|
||||
udp_stun: [],
|
||||
tcp_stun: [],
|
||||
tunnel_port: null,
|
||||
});
|
||||
|
||||
// 从TOML解析到表单
|
||||
export const parseTomlToForm = (toml) => {
|
||||
const data = emptyFormData();
|
||||
|
||||
const lines = toml.split("\n");
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
|
||||
if (trimmed.includes("config_name")) {
|
||||
const match = trimmed.match(/config_name\s*=\s*"([^"]*)"/);
|
||||
if (match) data.config_name = match[1];
|
||||
} else if (trimmed.includes("network_code")) {
|
||||
const match = trimmed.match(/network_code\s*=\s*"([^"]*)"/);
|
||||
if (match) data.network_code = match[1];
|
||||
} else if (trimmed.startsWith("server")) {
|
||||
const match = trimmed.match(/server\s*=\s*\[(.*)\]/);
|
||||
if (match) {
|
||||
const items = match[1].match(/"([^"]*)"/g);
|
||||
if (items) data.server = items.map((s) => s.replace(/"/g, ""));
|
||||
}
|
||||
} else if (trimmed.includes("ip =")) {
|
||||
const match = trimmed.match(/ip\s*=\s*"([^"]*)"/);
|
||||
if (match) data.ip = match[1];
|
||||
} else if (trimmed.includes("mtu =")) {
|
||||
const match = trimmed.match(/mtu\s*=\s*(\d+)/);
|
||||
if (match) data.mtu = parseInt(match[1]);
|
||||
} else if (trimmed.match(/^rtx\s*=/)) {
|
||||
data.rtx = trimmed.includes("true");
|
||||
} else if (trimmed.match(/^fec\s*=/)) {
|
||||
data.fec = trimmed.includes("true");
|
||||
} else if (trimmed.match(/^compress\s*=/)) {
|
||||
data.compress = trimmed.includes("true");
|
||||
} else if (trimmed.match(/^no_punch\s*=/)) {
|
||||
data.no_punch = trimmed.includes("true");
|
||||
} else if (trimmed.startsWith("input")) {
|
||||
const match = trimmed.match(/input\s*=\s*\[(.*)\]/);
|
||||
if (match) {
|
||||
const items = match[1].match(/"([^"]*)"/g);
|
||||
if (items) data.input = items.map((s) => s.replace(/"/g, ""));
|
||||
}
|
||||
} else if (trimmed.startsWith("output")) {
|
||||
const match = trimmed.match(/output\s*=\s*\[(.*)\]/);
|
||||
if (match) {
|
||||
const items = match[1].match(/"([^"]*)"/g);
|
||||
if (items) data.output = items.map((s) => s.replace(/"/g, ""));
|
||||
}
|
||||
} else if (trimmed.match(/^no_nat\s*=/)) {
|
||||
data.no_nat = trimmed.includes("true");
|
||||
} else if (trimmed.match(/^no_tun\s*=/)) {
|
||||
throw new Error('配置项 no_tun 已移除,请改用 device_mode = "no|tun|tap"');
|
||||
} else if (trimmed.match(/^device_mode\s*=/)) {
|
||||
const match = trimmed.match(/device_mode\s*=\s*"([^"]*)"/);
|
||||
if (!match || !["no", "tun", "tap"].includes(match[1])) {
|
||||
throw new Error('device_mode 必须是 "no"、"tun" 或 "tap"');
|
||||
}
|
||||
data.device_mode = match[1];
|
||||
} else if (trimmed.startsWith("port_mapping")) {
|
||||
const match = trimmed.match(/port_mapping\s*=\s*\[(.*)\]/);
|
||||
if (match) {
|
||||
const items = match[1].match(/"([^"]*)"/g);
|
||||
if (items) data.port_mapping = items.map((s) => s.replace(/"/g, ""));
|
||||
}
|
||||
} else if (trimmed.match(/^allow_mapping\s*=/)) {
|
||||
data.allow_mapping = trimmed.includes("true");
|
||||
} else if (trimmed.includes("device_name")) {
|
||||
const match = trimmed.match(/device_name\s*=\s*"([^"]*)"/);
|
||||
if (match) data.device_name = match[1];
|
||||
} else if (trimmed.includes("device_id")) {
|
||||
const match = trimmed.match(/device_id\s*=\s*"([^"]*)"/);
|
||||
if (match) data.device_id = match[1];
|
||||
} else if (trimmed.includes("tun_name")) {
|
||||
const match = trimmed.match(/tun_name\s*=\s*"([^"]*)"/);
|
||||
if (match) data.tun_name = match[1];
|
||||
} else if (trimmed.includes("outbound_interface")) {
|
||||
const match = trimmed.match(/outbound_interface\s*=\s*"([^"]*)"/);
|
||||
if (match) data.outbound_interface = match[1];
|
||||
} else if (trimmed.includes("password =")) {
|
||||
const match = trimmed.match(/password\s*=\s*"([^"]*)"/);
|
||||
if (match) data.password = match[1];
|
||||
} else if (trimmed.includes("cert_mode")) {
|
||||
const match = trimmed.match(/cert_mode\s*=\s*"([^"]*)"/);
|
||||
if (match) {
|
||||
const value = match[1];
|
||||
if (value.startsWith("finger:")) {
|
||||
data.cert_mode = "finger";
|
||||
data.fingerprint = value.substring(7); // 去掉 "finger:" 前缀
|
||||
} else {
|
||||
data.cert_mode = value;
|
||||
}
|
||||
}
|
||||
} else if (trimmed.startsWith("udp_stun")) {
|
||||
const match = trimmed.match(/udp_stun\s*=\s*\[(.*)\]/);
|
||||
if (match) {
|
||||
const items = match[1].match(/"([^"]*)"/g);
|
||||
if (items) data.udp_stun = items.map((s) => s.replace(/"/g, ""));
|
||||
}
|
||||
} else if (trimmed.startsWith("tcp_stun")) {
|
||||
const match = trimmed.match(/tcp_stun\s*=\s*\[(.*)\]/);
|
||||
if (match) {
|
||||
const items = match[1].match(/"([^"]*)"/g);
|
||||
if (items) data.tcp_stun = items.map((s) => s.replace(/"/g, ""));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
// 从表单生成TOML
|
||||
export const formToToml = (formData) => {
|
||||
let toml = "";
|
||||
|
||||
if (formData.config_name) {
|
||||
toml += `# 配置名称\nconfig_name = "${formData.config_name}"\n`;
|
||||
}
|
||||
|
||||
toml += "\n# --- 网络配置 ---\n";
|
||||
toml += "# 网络编号,相同网络编号的会组在同一个虚拟网 (必填)\n";
|
||||
toml += `network_code = "${formData.network_code}"\n\n`;
|
||||
|
||||
const servers = formData.server.filter((s) => s.trim());
|
||||
if (servers.length > 0) {
|
||||
toml += "# 服务器地址列表(支持 quic / tcp / wss / dynamic) (必填)\n";
|
||||
toml += "# dynamic 协议使用dns txt解析记录值\n";
|
||||
toml += `server = [${servers.map((s) => `"${s}"`).join(", ")}]\n`;
|
||||
}
|
||||
|
||||
if (formData.ip) {
|
||||
toml += "\n# 自定义虚拟 IP (可选)\n";
|
||||
toml += `ip = "${formData.ip}"\n`;
|
||||
}
|
||||
|
||||
if (formData.rtx) {
|
||||
toml += "\n# 是否启用quic优化传输 (默认 false)\n";
|
||||
toml += "# 开启后传输过程几乎不会丢包,但是延迟可能会有波动\n";
|
||||
toml += "rtx = true\n";
|
||||
}
|
||||
|
||||
if (formData.fec) {
|
||||
toml += "\n# 是否启用 FEC 前向纠错 (默认 false)\n";
|
||||
toml += "# 开启后可以减少丢包率,损失带宽但是延迟比较稳定,带宽充足时可以使用此功能\n";
|
||||
toml += "fec = true\n";
|
||||
}
|
||||
|
||||
if (formData.no_punch) {
|
||||
toml += "\n# 是否关闭 P2P 打洞 (默认 false)\n";
|
||||
toml += "no_punch = true\n";
|
||||
}
|
||||
|
||||
if (formData.compress) {
|
||||
toml += "\n# 是否启用 LZ4 压缩 (默认 false)\n";
|
||||
toml += "compress = true\n";
|
||||
}
|
||||
|
||||
const inputs = formData.input.filter((s) => s.trim());
|
||||
if (inputs.length > 0) {
|
||||
toml += "\n# 入栈监听网段 (逗号分隔的 CIDR 和目标 IP),用于点对网,将指定网段的流量发送到目标节点\n";
|
||||
toml += "# 例如192.168.0.0/24,10.26.0.2 表示将192.168.0.0/24网段的数据转发到10.26.0.2\n";
|
||||
toml += `input = [${inputs.map((s) => `"${s}"`).join(", ")}]\n`;
|
||||
}
|
||||
|
||||
const outputs = formData.output.filter((s) => s.trim());
|
||||
if (outputs.length > 0) {
|
||||
toml += "\n# 出栈允许网段,用于点对网,允许指定网段的转发\n";
|
||||
toml += `output = [${outputs.map((s) => `"${s}"`).join(", ")}]\n`;
|
||||
}
|
||||
|
||||
if (formData.no_nat) {
|
||||
toml += "\n# 是否关闭内置子网NAT,关闭后需要配置网卡转发,否则无法使用点对网\n";
|
||||
toml += "# 通常关闭内置子网NAT,使用系统的网卡转发,点对网性能会更好\n";
|
||||
toml += "no_nat = true\n";
|
||||
}
|
||||
|
||||
toml += "\n# 虚拟网卡模式:no(无网卡)、tun(三层网卡)、tap(二层网卡)\n";
|
||||
toml += `device_mode = "${formData.device_mode || "tun"}"\n`;
|
||||
|
||||
const portMappings = formData.port_mapping.filter((s) => s.trim());
|
||||
if (portMappings.length > 0) {
|
||||
toml += "\n# 端口映射,格式为:协议://本地监听地址-目标虚拟IP-目标映射地址\n";
|
||||
toml += "# 端口映射用于在本地监听指定端口,并将收到的网络流量经由指定虚拟节点转发到目标地址\n";
|
||||
toml += "# 例如: tcp://0.0.0.0:81-10.0.0.2-10.0.0.2:80 表示将本地tcp的81端口的数据转发到10.0.0.2:80\n";
|
||||
toml += "# 例如: tcp://0.0.0.0:81-10.0.0.2-192.168.1.10:80 则表示将本地tcp的81端口的数据经过10.0.0.2转到192.168.1.10:80\n";
|
||||
toml += "# 例如: tcp://0.0.0.0:81-10.0.0.2-anyonehost:80 则表示将本地tcp的81端口的数据经过10.0.0.2转到anyonehost:80\n";
|
||||
toml += `port_mapping = [${portMappings.map((s) => `"${s}"`).join(", ")}]\n`;
|
||||
}
|
||||
|
||||
if (formData.allow_mapping) {
|
||||
toml += '\n# 是否允许作为端口映射出口,开启后其他设备才可使用本设备的ip为"目标虚拟IP"\n';
|
||||
toml += "# 开启后虚拟网络其他设备可以使用此设备当跳板访问其他网络\n";
|
||||
toml += "allow_mapping = true\n";
|
||||
}
|
||||
|
||||
if (formData.mtu) {
|
||||
toml += "\n# MTU 设置\n";
|
||||
toml += `mtu = ${formData.mtu}\n`;
|
||||
}
|
||||
|
||||
toml += "\n# --- 设备配置 ---\n";
|
||||
if (formData.device_name) {
|
||||
toml += "\n# 设备名称 (可选,默认读取本机 hostname)\n";
|
||||
toml += `device_name = "${formData.device_name}"\n`;
|
||||
}
|
||||
if (formData.device_id) {
|
||||
toml += "\n# 设备 ID (可选,不填自动生成,不同设备ID不能相同)\n";
|
||||
toml += `device_id = "${formData.device_id}"\n`;
|
||||
}
|
||||
if (formData.tun_name) {
|
||||
toml += "\n# 虚拟网卡名称\n";
|
||||
toml += `tun_name = "${formData.tun_name}"\n`;
|
||||
}
|
||||
if (formData.outbound_interface) {
|
||||
toml += "\n# 绑定对外通信 Socket 的出口网卡名称(用于服务端通信、P2P 打洞及转发流量)\n";
|
||||
toml += `outbound_interface = "${formData.outbound_interface}"\n`;
|
||||
}
|
||||
|
||||
toml += "\n# --- 安全配置 ---\n";
|
||||
if (formData.password) {
|
||||
toml += "\n# 组网加密密码 (可选)\n";
|
||||
toml += `password = "${formData.password}"\n`;
|
||||
}
|
||||
if (formData.cert_mode && formData.cert_mode !== "skip") {
|
||||
toml += "\n# 证书校验方式:\n";
|
||||
toml += "# skip 跳过验证(默认)\n";
|
||||
toml += "# standard 使用系统证书验证\n";
|
||||
toml += "# finger 使用证书指纹验证,服务端启动时日志会输出指纹\n";
|
||||
if (formData.cert_mode === "finger" && formData.fingerprint) {
|
||||
toml += `cert_mode = "finger:${formData.fingerprint}"\n`;
|
||||
} else {
|
||||
toml += `cert_mode = "${formData.cert_mode}"\n`;
|
||||
}
|
||||
}
|
||||
|
||||
const udpStuns = formData.udp_stun.filter((s) => s.trim());
|
||||
if (udpStuns.length > 0) {
|
||||
toml += "\n# 自定义UDP STUN地址,不设置则用默认stun\n";
|
||||
toml += `udp_stun = [${udpStuns.map((s) => `"${s}"`).join(", ")}]\n`;
|
||||
}
|
||||
|
||||
const tcpStuns = formData.tcp_stun.filter((s) => s.trim());
|
||||
if (tcpStuns.length > 0) {
|
||||
toml += "\n# 自定义TCP STUN地址,不设置则用默认stun\n";
|
||||
toml += `tcp_stun = [${tcpStuns.map((s) => `"${s}"`).join(", ")}]\n`;
|
||||
}
|
||||
|
||||
return toml;
|
||||
};
|
||||
|
||||
// 新建配置的 TOML 模板(从旧代码逐字迁移)
|
||||
export const NEW_CONFIG_TEMPLATE = `# config_name = "配置名称"
|
||||
# --- 网络配置 ---
|
||||
# 网络编号,相同网络编号的会组在同一个虚拟网 (必填)
|
||||
network_code = "your_network_code"
|
||||
|
||||
# 服务器地址列表(支持 quic / tcp / wss / dynamic) (必填)
|
||||
# dynamic 协议使用dns txt解析记录值
|
||||
server = ["quic://1.2.3.4:29872"]
|
||||
|
||||
# ===简单使用以下参数可以不动===
|
||||
|
||||
# 自定义虚拟 IP (可选)
|
||||
# ip = "10.10.0.2"
|
||||
|
||||
# 是否启用quic优化传输 (默认 false,设置为true时开启)
|
||||
# 开启后传输过程几乎不会丢包,但是延迟会有波动
|
||||
# rtx = false
|
||||
|
||||
# 是否启用 FEC 前向纠错,损失一定带宽来提升网络稳定性(默认 false,设置为true时开启)
|
||||
# 开启后可以减少丢包率,损失带宽但是延迟比较稳定,带宽充足时可以使用此功能
|
||||
# fec = false
|
||||
|
||||
# 是否关闭 P2P 打洞 (默认 false,设置为true时关闭)
|
||||
# no_punch = false
|
||||
|
||||
# 是否启用 LZ4 压缩 (默认 false,设置为true时开启)
|
||||
# compress = false
|
||||
|
||||
# 入栈监听网段 (逗号分隔的 CIDR 和目标 IP),用于点对网,将指定网段的流量发送到目标节点
|
||||
# input = ["192.168.0.0/24,10.26.0.2", "192.168.1.0/24,10.26.0.3"]
|
||||
|
||||
# 出栈允许网段,用于点对网,允许指定网段的转发
|
||||
# output = ["0.0.0.0/0"]
|
||||
|
||||
# 是否关闭内置子网NAT,关闭(设为true)后需要配置网卡转发,否则无法使用点对网。通常关闭内置子网NAT,使用系统的网卡转发,点对网性能会更好
|
||||
# no_nat = false
|
||||
|
||||
# 虚拟网卡模式:no(无网卡)、tun(三层网卡,默认)、tap(二层网卡)
|
||||
# Windows 的 tap 模式需要预先安装 tap-windows (tap0901) 驱动
|
||||
device_mode = "tun"
|
||||
|
||||
# 端口映射,格式为:协议://本地监听地址-目标虚拟IP-目标映射地址
|
||||
# 端口映射用于在本地监听指定端口,并将收到的网络流量经由指定虚拟节点转发到目标地址,从而实现跨网络或内网服务访问
|
||||
# 例如 port_mapping = ["tcp://0.0.0.0:81-10.0.0.2-10.0.0.2:80"]
|
||||
# tcp://0.0.0.0:81-10.0.0.2-10.0.0.2:80 则表示将本地tcp的81端口的数据转发到10.0.0.2:80
|
||||
# tcp://0.0.0.0:81-10.0.0.2-192.168.1.10:80 则表示将本地tcp的81端口的数据经过10.0.0.2转到192.168.1.10:80
|
||||
# tcp://0.0.0.0:81-10.0.0.2-anyonehost:80 则表示将本地tcp的81端口的数据经过10.0.0.2转到anyonehost:80
|
||||
# port_mapping = []
|
||||
|
||||
# 是否允许作为端口映射出口,开启(设置为true)后其他设备才可使用本设备的ip为"目标虚拟IP"
|
||||
# 开启后虚拟网络其他设备可以使用此设备当跳板访问其他网络
|
||||
# allow_mapping = false
|
||||
|
||||
# MTU 设置
|
||||
# mtu = 1400
|
||||
|
||||
# --- 设备配置 ---
|
||||
|
||||
# 设备名称 (可选,默认读取本机 hostname)
|
||||
# device_name = "my-device"
|
||||
|
||||
# 设备 ID (可选,不填自动生成,不同设备ID不能相同)
|
||||
# device_id = "device-id-xxxx"
|
||||
|
||||
# 虚拟网卡名称
|
||||
# tun_name = "vnt-tun"
|
||||
|
||||
# 绑定对外通信 Socket 的出口网卡名称(例如 Ethernet、Wi-Fi、eth0)
|
||||
# outbound_interface = "Ethernet"
|
||||
|
||||
# --- 安全配置 ---
|
||||
|
||||
# 加密密码 (可选)
|
||||
# password = "123456"
|
||||
|
||||
# 证书校验方式:
|
||||
# skip 跳过验证(默认)
|
||||
# standard 使用系统证书验证
|
||||
# finger 使用证书指纹验证,服务端启动时日志会输出指纹,
|
||||
# 例如 finger:3bdd8675606837cdf95d5e13445606315762315a78555f9da652940a25feaec1
|
||||
# cert_mode = "skip"
|
||||
|
||||
# --- 其他配置 ---
|
||||
# 自定义stun地址,分别用于udp打洞和tcp打洞,需要单独配置,不设置则用默认stun
|
||||
# udp_stun = ["stun.chat.bilibili.com"]
|
||||
# tcp_stun = ["stun.nextcloud.com:443"]`;
|
||||
@@ -0,0 +1,231 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useAppStore } from "../stores/app";
|
||||
import { getRuntime, getVersion } from "../api";
|
||||
import { isDesktop } from "../auth";
|
||||
import vntIcon from "../assets/vnt-icon.png";
|
||||
|
||||
const PROJECT_URL = "https://github.com/vnt-dev/vnt";
|
||||
const RELEASES_URL = `${PROJECT_URL}/releases`;
|
||||
const RELEASES_API = "https://api.github.com/repos/vnt-dev/vnt/releases?per_page=20";
|
||||
|
||||
const app = useAppStore();
|
||||
const runtime = ref(isDesktop ? "desktop" : "");
|
||||
const checking = ref(false);
|
||||
const installing = ref(false);
|
||||
const updateInfo = ref(null);
|
||||
const resultKind = ref("");
|
||||
const message = ref("");
|
||||
const downloaded = ref(0);
|
||||
const contentLength = ref(0);
|
||||
|
||||
const currentVersion = computed(() => app.version || "2.0.2");
|
||||
const progress = computed(() => {
|
||||
if (!contentLength.value) return 0;
|
||||
return Math.min(100, Math.round((downloaded.value / contentLength.value) * 100));
|
||||
});
|
||||
|
||||
const versionParts = (value) => {
|
||||
const match = String(value || "").trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:[+-].*)?$/);
|
||||
return match ? match.slice(1).map(Number) : null;
|
||||
};
|
||||
|
||||
const compareVersions = (left, right) => {
|
||||
const a = versionParts(left);
|
||||
const b = versionParts(right);
|
||||
if (!a || !b) return 0;
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
if (a[index] !== b[index]) return a[index] > b[index] ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const openUrl = async (url) => {
|
||||
if (globalThis.__VNT_WEB_ACCESS__?.openUrl) {
|
||||
await globalThis.__VNT_WEB_ACCESS__.openUrl(url);
|
||||
} else {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
};
|
||||
|
||||
const checkGithubRelease = async () => {
|
||||
const response = await fetch(RELEASES_API, {
|
||||
headers: { Accept: "application/vnd.github+json" },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok) throw new Error(`GitHub 返回 ${response.status}`);
|
||||
const releases = (await response.json()).filter(
|
||||
(release) => !release.draft && !release.prerelease && versionParts(release.tag_name),
|
||||
);
|
||||
releases.sort((a, b) => compareVersions(b.tag_name, a.tag_name));
|
||||
const latest = releases[0];
|
||||
if (!latest) throw new Error("没有找到可用的发布版本");
|
||||
return {
|
||||
version: latest.tag_name.replace(/^v/, ""),
|
||||
body: latest.body || "",
|
||||
url: latest.html_url || RELEASES_URL,
|
||||
};
|
||||
};
|
||||
|
||||
const checkUpdate = async () => {
|
||||
checking.value = true;
|
||||
resultKind.value = "";
|
||||
message.value = "";
|
||||
updateInfo.value = null;
|
||||
try {
|
||||
if (isDesktop) {
|
||||
let update;
|
||||
try {
|
||||
update = await globalThis.__VNT_UPDATER__?.check();
|
||||
} catch {
|
||||
const latest = await checkGithubRelease();
|
||||
if (compareVersions(latest.version, currentVersion.value) <= 0) {
|
||||
resultKind.value = "latest";
|
||||
message.value = "当前已是最新版本";
|
||||
return;
|
||||
}
|
||||
updateInfo.value = { ...latest, manualOnly: true };
|
||||
resultKind.value = "update";
|
||||
message.value = `发现新版本 v${latest.version},该版本暂未提供自动更新包。`;
|
||||
return;
|
||||
}
|
||||
if (!update) {
|
||||
resultKind.value = "latest";
|
||||
message.value = "当前已是最新版本";
|
||||
return;
|
||||
}
|
||||
updateInfo.value = { ...update, url: RELEASES_URL };
|
||||
resultKind.value = "update";
|
||||
message.value = `发现新版本 v${update.version},可以直接下载并更新。`;
|
||||
return;
|
||||
}
|
||||
|
||||
runtime.value ||= await getRuntime();
|
||||
const latest = await checkGithubRelease();
|
||||
if (compareVersions(latest.version, currentVersion.value) <= 0) {
|
||||
resultKind.value = "latest";
|
||||
message.value = "当前已是最新版本";
|
||||
return;
|
||||
}
|
||||
updateInfo.value = latest;
|
||||
resultKind.value = "update";
|
||||
message.value = runtime.value === "desktop_web"
|
||||
? `发现新版本 v${latest.version},请回到 VNT Desktop 的“关于”页面完成更新。`
|
||||
: `发现新版本 v${latest.version},请下载新版本并替换当前 vnt2_web 程序。`;
|
||||
} catch (error) {
|
||||
resultKind.value = "error";
|
||||
message.value = `检查更新失败:${error?.message || error}`;
|
||||
} finally {
|
||||
checking.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const downloadAndInstall = async () => {
|
||||
installing.value = true;
|
||||
downloaded.value = 0;
|
||||
contentLength.value = 0;
|
||||
message.value = "正在准备下载更新…";
|
||||
try {
|
||||
await globalThis.__VNT_UPDATER__.downloadAndInstall((event) => {
|
||||
downloaded.value = event.downloaded;
|
||||
contentLength.value = event.contentLength;
|
||||
message.value = event.event === "Finished" ? "下载完成,正在安装…" : "正在下载更新…";
|
||||
});
|
||||
} catch (error) {
|
||||
resultKind.value = "error";
|
||||
message.value = `更新失败:${error?.message || error}`;
|
||||
installing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
if (!app.version) {
|
||||
try {
|
||||
app.version = await getVersion();
|
||||
} catch {
|
||||
// 顶栏的版本加载逻辑仍会继续重试。
|
||||
}
|
||||
}
|
||||
if (!isDesktop) {
|
||||
try {
|
||||
runtime.value = await getRuntime();
|
||||
} catch {
|
||||
runtime.value = "standalone_web";
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-3xl space-y-5">
|
||||
<div>
|
||||
<h1 class="page-title">关于</h1>
|
||||
<p class="page-subtitle">VNT 客户端信息与软件更新</p>
|
||||
</div>
|
||||
|
||||
<section class="card flex items-center gap-4">
|
||||
<img :src="vntIcon" alt="VNT" class="h-16 w-16 shrink-0 rounded-2xl" />
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-lg font-bold text-slate-900 dark:text-white">VNT</h2>
|
||||
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">简单、高效的异地组网与内网穿透工具</p>
|
||||
<p class="mt-2 font-mono text-xs text-slate-400">当前版本 v{{ currentVersion }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="text-sm font-semibold text-slate-900 dark:text-white">开源项目</h2>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-500 dark:text-slate-400">项目代码、使用说明和问题反馈均托管在 GitHub。</p>
|
||||
<button class="btn-ghost mt-4" type="button" @click="openUrl(PROJECT_URL)">
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M14 5h5v5m0-5-9 9M19 13v5a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" />
|
||||
</svg>
|
||||
github.com/vnt-dev/vnt
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-slate-900 dark:text-white">软件更新</h2>
|
||||
<p class="mt-2 text-sm text-slate-500 dark:text-slate-400">
|
||||
{{ isDesktop ? "检查并安装 VNT Desktop 的最新版本。" : "检查 GitHub 上发布的最新版本。" }}
|
||||
</p>
|
||||
</div>
|
||||
<button class="btn-primary" type="button" :disabled="checking || installing" @click="checkUpdate">
|
||||
{{ checking ? "正在检查…" : "检查更新" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="message"
|
||||
class="mt-5 rounded-lg border px-4 py-3 text-sm"
|
||||
:class="resultKind === 'error'
|
||||
? 'border-red-200 bg-red-50 text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300'
|
||||
: resultKind === 'update'
|
||||
? 'border-indigo-200 bg-indigo-50 text-indigo-700 dark:border-indigo-900 dark:bg-indigo-950/40 dark:text-indigo-300'
|
||||
: 'border-slate-200 bg-slate-50 text-slate-600 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300'"
|
||||
>
|
||||
{{ message }}
|
||||
</div>
|
||||
|
||||
<div v-if="installing && contentLength" class="mt-4">
|
||||
<div class="mb-1.5 flex justify-between text-xs text-slate-400">
|
||||
<span>下载进度</span>
|
||||
<span>{{ progress }}%</span>
|
||||
</div>
|
||||
<div class="h-1.5 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700">
|
||||
<div class="h-full rounded-full bg-indigo-600 transition-[width] dark:bg-indigo-500" :style="{ width: `${progress}%` }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="resultKind === 'update'" class="mt-4 flex flex-wrap gap-2">
|
||||
<button v-if="isDesktop && !updateInfo?.manualOnly" class="btn-primary" type="button" :disabled="installing" @click="downloadAndInstall">
|
||||
{{ installing ? "正在更新…" : "下载并更新" }}
|
||||
</button>
|
||||
<button v-else-if="updateInfo?.manualOnly || runtime === 'standalone_web'" class="btn-ghost" type="button" @click="openUrl(updateInfo?.url || RELEASES_URL)">查看发布版本</button>
|
||||
</div>
|
||||
|
||||
<p v-if="isDesktop" class="mt-4 text-xs leading-5 text-slate-400">安装更新时桌面客户端可能自动退出,完成后将重新启动。</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,630 @@
|
||||
<script setup>
|
||||
import { ref, watch, nextTick } from "vue";
|
||||
import AppModal from "../components/AppModal.vue";
|
||||
import AppSelect from "../components/AppSelect.vue";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
import { getConfig, saveConfig } from "../api";
|
||||
import { emptyFormData, parseTomlToForm, formToToml, NEW_CONFIG_TEMPLATE } from "../utils/toml";
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
// null 表示新建
|
||||
fileName: { type: String, default: null },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "saved"]);
|
||||
|
||||
const ui = useUiStore();
|
||||
|
||||
const editorContent = ref("");
|
||||
const editorFileName = ref("");
|
||||
const editorMode = ref("new"); // 'new' 或 'edit'
|
||||
const editMode = ref("form"); // 'form' 或 'toml'
|
||||
const originalToml = ref(""); // 保存原始TOML内容(包含用户注释)
|
||||
const hasTomlChanges = ref(false);
|
||||
const hasFormChanges = ref(false);
|
||||
const isParsingToml = ref(false);
|
||||
const formData = ref(emptyFormData());
|
||||
const certificateModeOptions = [
|
||||
{ value: "skip", label: "跳过验证(默认)" },
|
||||
{ value: "standard", label: "系统证书验证" },
|
||||
{ value: "finger", label: "证书指纹验证" },
|
||||
];
|
||||
const deviceModeOptions = [
|
||||
{ value: "no", label: "无虚拟网卡" },
|
||||
{ value: "tun", label: "TUN(三层)" },
|
||||
{ value: "tap", label: "TAP(二层)" },
|
||||
];
|
||||
|
||||
// 打开时加载内容
|
||||
watch(
|
||||
() => props.show,
|
||||
async (val) => {
|
||||
if (!val) return;
|
||||
editorFileName.value = props.fileName || "";
|
||||
editorMode.value = props.fileName ? "edit" : "new";
|
||||
editMode.value = "form"; // 默认表单模式
|
||||
hasTomlChanges.value = false;
|
||||
hasFormChanges.value = false;
|
||||
|
||||
if (props.fileName) {
|
||||
try {
|
||||
const data = await getConfig(props.fileName);
|
||||
editorContent.value = data;
|
||||
originalToml.value = data; // 保存原始TOML
|
||||
isParsingToml.value = true;
|
||||
formData.value = parseTomlToForm(data);
|
||||
nextTick(() => {
|
||||
isParsingToml.value = false;
|
||||
});
|
||||
} catch (e) {
|
||||
ui.toast.error("获取配置失败: " + e.message);
|
||||
emit("close");
|
||||
}
|
||||
} else {
|
||||
// 新建配置,初始化表单
|
||||
originalToml.value = "";
|
||||
formData.value = emptyFormData();
|
||||
editorContent.value = NEW_CONFIG_TEMPLATE;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 切换到表单模式
|
||||
const switchToFormMode = () => {
|
||||
if (editMode.value === "toml") {
|
||||
try {
|
||||
const parsed = parseTomlToForm(editorContent.value);
|
||||
editMode.value = "form";
|
||||
isParsingToml.value = true;
|
||||
formData.value = parsed;
|
||||
nextTick(() => {
|
||||
isParsingToml.value = false;
|
||||
});
|
||||
} catch (e) {
|
||||
ui.toast.error("配置解析失败: " + e.message);
|
||||
}
|
||||
} else {
|
||||
editMode.value = "form";
|
||||
}
|
||||
};
|
||||
|
||||
// 切换到TOML模式
|
||||
const switchToTomlMode = () => {
|
||||
if (editMode.value === "form") {
|
||||
// 如果表单被修改过,生成新的TOML
|
||||
if (hasFormChanges.value) {
|
||||
editorContent.value = formToToml(formData.value);
|
||||
hasFormChanges.value = false;
|
||||
} else if (originalToml.value && !hasTomlChanges.value) {
|
||||
// 表单未修改且TOML未修改,使用原始TOML(保留用户注释)
|
||||
editorContent.value = originalToml.value;
|
||||
} else {
|
||||
editorContent.value = formToToml(formData.value);
|
||||
}
|
||||
}
|
||||
editMode.value = "toml";
|
||||
};
|
||||
|
||||
// 监听TOML内容变化(只在TOML模式下)
|
||||
watch(editorContent, (newVal, oldVal) => {
|
||||
if (editMode.value === "toml" && oldVal !== undefined) {
|
||||
hasTomlChanges.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
// 监听表单数据变化
|
||||
watch(
|
||||
formData,
|
||||
() => {
|
||||
if (editMode.value === "form" && props.show && !isParsingToml.value) {
|
||||
hasFormChanges.value = true;
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
// 表单模式先转换为TOML
|
||||
let configContent = editorContent.value;
|
||||
if (editMode.value === "form") {
|
||||
// 验证必填项
|
||||
if (!formData.value.network_code.trim()) {
|
||||
ui.toast.error("请填写网络编号");
|
||||
return;
|
||||
}
|
||||
const servers = formData.value.server.filter((s) => s.trim());
|
||||
if (servers.length === 0) {
|
||||
ui.toast.error("请至少填写一个服务器地址");
|
||||
return;
|
||||
}
|
||||
configContent = formToToml(formData.value);
|
||||
}
|
||||
|
||||
await saveConfig(editorFileName.value || null, configContent);
|
||||
ui.toast.success("配置已保存");
|
||||
emit("close");
|
||||
emit("saved");
|
||||
} catch (e) {
|
||||
ui.toast.error("保存失败: " + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 表单分组内的小型重复控件
|
||||
const checkboxClass =
|
||||
"h-5 w-5 rounded border-slate-300 bg-white text-indigo-600 focus:ring-indigo-500 dark:border-slate-600 dark:bg-slate-700";
|
||||
const toggleLabelClass =
|
||||
"flex cursor-pointer items-center justify-between gap-2 rounded-lg border border-slate-200 bg-slate-50 p-3 transition-colors hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-800/50 dark:hover:bg-slate-800";
|
||||
const removeBtnClass =
|
||||
"shrink-0 rounded-lg bg-red-50 px-3 py-2 text-red-500 transition-colors hover:bg-red-100 dark:bg-red-900/20 dark:text-red-500 dark:hover:bg-red-900/40";
|
||||
const addBtnClass =
|
||||
"flex w-full items-center justify-center gap-1 rounded-lg bg-slate-100 px-3 py-2 text-sm text-slate-600 transition-colors hover:bg-slate-200 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700";
|
||||
const sectionTitleClass = "text-md mb-4 flex items-center font-bold text-slate-900 dark:text-white";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppModal
|
||||
:show="show"
|
||||
:mask-closable="false"
|
||||
panel-class="w-full max-w-6xl h-[85vh]"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-bold text-slate-900 dark:text-white">
|
||||
{{ editorMode === "new" ? "新建配置" : "编辑配置" }}
|
||||
</h3>
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- 模式切换按钮 -->
|
||||
<div class="flex rounded-lg border border-slate-300 bg-slate-100 p-1 dark:border-slate-600 dark:bg-slate-800">
|
||||
<button
|
||||
@click="switchToFormMode"
|
||||
:class="editMode === 'form' ? 'bg-white text-slate-900 shadow-sm dark:bg-indigo-600 dark:text-white' : 'text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white'"
|
||||
class="px-4 py-1.5 rounded text-sm font-medium transition-colors flex items-center"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
表单模式
|
||||
</button>
|
||||
<button
|
||||
@click="switchToTomlMode"
|
||||
:class="editMode === 'toml' ? 'bg-white text-slate-900 shadow-sm dark:bg-indigo-600 dark:text-white' : 'text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white'"
|
||||
class="px-4 py-1.5 rounded text-sm font-medium transition-colors flex items-center"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"
|
||||
/>
|
||||
</svg>
|
||||
TOML模式
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-sm text-slate-500 font-mono hidden md:block" v-if="editorFileName">
|
||||
{{ editorFileName }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<!-- 表单模式 -->
|
||||
<div v-show="editMode === 'form'" class="h-full overflow-y-auto scrollbar-hide p-6">
|
||||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
<!-- 基础配置 -->
|
||||
<div class="card">
|
||||
<h4 :class="sectionTitleClass">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
基础配置
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">配置名称</label>
|
||||
<input v-model="formData.config_name" type="text" placeholder="例如: 我的VPN配置" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">
|
||||
网络编号 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input v-model="formData.network_code" type="text" placeholder="例如: my_network" required class="input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">
|
||||
服务器地址 <span class="text-red-500">*</span>
|
||||
<span class="text-xs text-slate-500 ml-2">支持 quic:// tcp:// wss:// dynamic://</span>
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(server, idx) in formData.server" :key="idx" class="flex gap-2">
|
||||
<input v-model="formData.server[idx]" type="text" placeholder="例如: quic://1.2.3.4:29872" class="input flex-1" />
|
||||
<button @click="formData.server.splice(idx, 1)" v-if="formData.server.length > 1" :class="removeBtnClass">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@click="formData.server.push('')"
|
||||
:class="addBtnClass"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
添加服务器
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 网络设置 -->
|
||||
<div class="card">
|
||||
<h4 :class="sectionTitleClass">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"
|
||||
/>
|
||||
</svg>
|
||||
网络设置
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">
|
||||
自定义虚拟IP
|
||||
<span class="text-xs text-slate-500 ml-1">(可选)</span>
|
||||
</label>
|
||||
<input v-model="formData.ip" type="text" placeholder="例如: 10.26.0.2" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">MTU</label>
|
||||
<input v-model.number="formData.mtu" type="number" placeholder="1380" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">隧道端口</label>
|
||||
<input v-model.number="formData.tunnel_port" type="number" placeholder="0 (自动分配)" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 传输优化 -->
|
||||
<div class="card">
|
||||
<h4 :class="sectionTitleClass">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
传输优化
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<label :class="toggleLabelClass">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-slate-800 dark:text-white">QUIC传输优化</div>
|
||||
<div class="text-xs text-slate-400 mt-0.5">重传丢包</div>
|
||||
</div>
|
||||
<input v-model="formData.rtx" type="checkbox" :class="checkboxClass" />
|
||||
</label>
|
||||
<label :class="toggleLabelClass">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-slate-800 dark:text-white">FEC前向纠错</div>
|
||||
<div class="text-xs text-slate-400 mt-0.5">损失部分带宽提升稳定性</div>
|
||||
</div>
|
||||
<input v-model="formData.fec" type="checkbox" :class="checkboxClass" />
|
||||
</label>
|
||||
<label :class="toggleLabelClass">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-slate-800 dark:text-white">LZ4压缩</div>
|
||||
<div class="text-xs text-slate-400 mt-0.5">减少传输数据量</div>
|
||||
</div>
|
||||
<input v-model="formData.compress" type="checkbox" :class="checkboxClass" />
|
||||
</label>
|
||||
<label :class="toggleLabelClass">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-slate-800 dark:text-white">关闭P2P打洞</div>
|
||||
<div class="text-xs text-slate-400 mt-0.5">仅通过服务器中转</div>
|
||||
</div>
|
||||
<input v-model="formData.no_punch" type="checkbox" :class="checkboxClass" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 安全配置 -->
|
||||
<div class="card">
|
||||
<h4 :class="sectionTitleClass">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
安全配置
|
||||
</h4>
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300"
|
||||
>组网加密密码(同一组网密码需要相同)</label
|
||||
>
|
||||
<input v-model="formData.password" type="password" placeholder="留空则不加密" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">服务端证书校验模式</label>
|
||||
<AppSelect v-model="formData.cert_mode" :options="certificateModeOptions" aria-label="服务端证书校验模式" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="formData.cert_mode === 'finger'">
|
||||
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">
|
||||
证书指纹
|
||||
<span class="text-xs text-slate-500 ml-1">(服务端启动时日志会输出指纹)</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="formData.fingerprint"
|
||||
type="text"
|
||||
placeholder="例如: 3bdd8675606837cdf95d5e13445606315762315a78555f9da652940a25feaec1"
|
||||
class="input font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NAT与路由 -->
|
||||
<div class="card">
|
||||
<h4 :class="sectionTitleClass">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 01-.806-.98L15 9m0 0V7m0 2v6"
|
||||
/>
|
||||
</svg>
|
||||
NAT与路由 (点对网)
|
||||
</h4>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">
|
||||
入栈网段
|
||||
<span class="text-xs text-slate-500 ml-1">格式: CIDR,目标IP</span>
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(item, idx) in formData.input" :key="idx" class="flex gap-2">
|
||||
<input v-model="formData.input[idx]" type="text" placeholder="例如: 192.168.0.0/24,10.26.0.2" class="input flex-1" />
|
||||
<button @click="formData.input.splice(idx, 1)" :class="removeBtnClass">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@click="formData.input.push('')"
|
||||
:class="addBtnClass"
|
||||
>
|
||||
+ 添加入栈网段
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">
|
||||
出栈网段
|
||||
<span class="text-xs text-slate-500 ml-1">格式: CIDR (允许转发的网段)</span>
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(item, idx) in formData.output" :key="idx" class="flex gap-2">
|
||||
<input v-model="formData.output[idx]" type="text" placeholder="例如: 0.0.0.0/0" class="input flex-1" />
|
||||
<button @click="formData.output.splice(idx, 1)" :class="removeBtnClass">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@click="formData.output.push('')"
|
||||
:class="addBtnClass"
|
||||
>
|
||||
+ 添加出栈网段
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<label :class="toggleLabelClass">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-slate-800 dark:text-white">关闭内置NAT</div>
|
||||
<div class="text-xs text-slate-400 mt-0.5">使用系统网卡转发</div>
|
||||
</div>
|
||||
<input v-model="formData.no_nat" type="checkbox" :class="checkboxClass" />
|
||||
</label>
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-3 dark:border-slate-700 dark:bg-slate-800/50">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-slate-800 dark:text-white">虚拟网卡模式</div>
|
||||
<div class="text-xs text-slate-400 mt-0.5">TAP 为二层网卡;Windows 需要 tap-windows 驱动</div>
|
||||
</div>
|
||||
<AppSelect v-model="formData.device_mode" :options="deviceModeOptions" class="mt-2" aria-label="虚拟网卡模式" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 端口映射 -->
|
||||
<div class="card">
|
||||
<h4 :class="sectionTitleClass">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
端口映射
|
||||
</h4>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">
|
||||
映射规则
|
||||
<span class="text-xs text-slate-500 ml-1">格式: 协议://监听地址-虚拟IP-目标地址</span>
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(item, idx) in formData.port_mapping" :key="idx" class="flex gap-2">
|
||||
<input
|
||||
v-model="formData.port_mapping[idx]"
|
||||
type="text"
|
||||
placeholder="例如: tcp://0.0.0.0:81-10.0.0.2-10.0.0.2:80"
|
||||
class="input flex-1 font-mono"
|
||||
/>
|
||||
<button @click="formData.port_mapping.splice(idx, 1)" :class="removeBtnClass">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@click="formData.port_mapping.push('')"
|
||||
:class="addBtnClass"
|
||||
>
|
||||
+ 添加映射规则
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<label :class="toggleLabelClass">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-slate-800 dark:text-white">允许作为映射出口</div>
|
||||
<div class="text-xs text-slate-400 mt-0.5">允许其他设备使用本机作跳板来进行端口映射</div>
|
||||
</div>
|
||||
<input v-model="formData.allow_mapping" type="checkbox" :class="checkboxClass" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设备配置 -->
|
||||
<div class="card">
|
||||
<h4 :class="sectionTitleClass">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
设备配置
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">设备名称</label>
|
||||
<input v-model="formData.device_name" type="text" placeholder="默认为主机名" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">设备ID</label>
|
||||
<input v-model="formData.device_id" type="text" placeholder="自动生成" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">虚拟网卡名</label>
|
||||
<input v-model="formData.tun_name" type="text" placeholder="默认为vnt-tun" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">绑定出口网卡</label>
|
||||
<input
|
||||
v-model="formData.outbound_interface"
|
||||
type="text"
|
||||
placeholder="例如 Ethernet、Wi-Fi、eth0"
|
||||
class="input"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs leading-5 text-slate-400">服务端通信、P2P 打洞及转发流量将使用此网卡</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STUN配置 -->
|
||||
<div class="card">
|
||||
<h4 :class="sectionTitleClass">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
STUN配置 (高级)
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">UDP STUN服务器</label>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(item, idx) in formData.udp_stun" :key="idx" class="flex gap-2">
|
||||
<input v-model="formData.udp_stun[idx]" type="text" placeholder="例如: stun.l.google.com:19302" class="input flex-1" />
|
||||
<button @click="formData.udp_stun.splice(idx, 1)" :class="removeBtnClass">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@click="formData.udp_stun.push('')"
|
||||
:class="addBtnClass"
|
||||
>
|
||||
+ 添加UDP STUN
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">TCP STUN服务器</label>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(item, idx) in formData.tcp_stun" :key="idx" class="flex gap-2">
|
||||
<input v-model="formData.tcp_stun[idx]" type="text" placeholder="例如: stun.nextcloud.com:443" class="input flex-1" />
|
||||
<button @click="formData.tcp_stun.splice(idx, 1)" :class="removeBtnClass">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@click="formData.tcp_stun.push('')"
|
||||
:class="addBtnClass"
|
||||
>
|
||||
+ 添加TCP STUN
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TOML模式 -->
|
||||
<div v-show="editMode === 'toml'" class="h-full">
|
||||
<textarea
|
||||
v-model="editorContent"
|
||||
class="h-full w-full resize-none bg-slate-50 p-4 font-mono text-sm text-slate-800 focus:outline-none dark:bg-slate-950 dark:text-slate-200"
|
||||
spellcheck="false"
|
||||
placeholder="# 在此处输入 TOML 配置..."
|
||||
></textarea>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex-1 text-left text-xs text-slate-500">
|
||||
<span v-if="editMode === 'form'">填写完成后保存即可生成配置文件</span>
|
||||
<span v-else>* 请使用标准 TOML 格式</span>
|
||||
</div>
|
||||
<button class="btn-ghost" @click="emit('close')">取消</button>
|
||||
<button class="btn-primary" @click="handleSave">保存配置</button>
|
||||
</template>
|
||||
</AppModal>
|
||||
</template>
|
||||
@@ -0,0 +1,227 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import QRCode from "qrcode";
|
||||
import { useAppStore } from "../stores/app";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
import { deleteConfig, getConfig } from "../api";
|
||||
import EmptyState from "../components/EmptyState.vue";
|
||||
import AppModal from "../components/AppModal.vue";
|
||||
import ConfigEditor from "./ConfigEditor.vue";
|
||||
import { parseTomlToForm } from "../utils/toml";
|
||||
import { buildNetworkQrPayload } from "../utils/networkQr";
|
||||
|
||||
const app = useAppStore();
|
||||
const ui = useUiStore();
|
||||
|
||||
const showEditor = ref(false);
|
||||
const editorFileName = ref(null);
|
||||
const showQr = ref(false);
|
||||
const qrLoading = ref(false);
|
||||
const qrImage = ref("");
|
||||
const qrConfig = ref(null);
|
||||
const qrError = ref("");
|
||||
|
||||
// 配置对应的实例运行状态(无实例返回 null)
|
||||
const instStatus = (fileName) => {
|
||||
const inst = app.instanceList.find((i) => i.file_name === fileName);
|
||||
return inst ? inst.status : null;
|
||||
};
|
||||
|
||||
const cardClass = (fileName) => {
|
||||
const status = instStatus(fileName);
|
||||
if (status === "running") return "ring-2 ring-green-500/60";
|
||||
if (status === "starting") return "ring-2 ring-indigo-500/60";
|
||||
return "";
|
||||
};
|
||||
|
||||
const openEditor = (fileName) => {
|
||||
editorFileName.value = fileName;
|
||||
showEditor.value = true;
|
||||
};
|
||||
|
||||
const onSaved = () => {
|
||||
app.fetchConfigList();
|
||||
};
|
||||
|
||||
const openQr = async (fileName) => {
|
||||
showQr.value = true;
|
||||
qrLoading.value = true;
|
||||
qrImage.value = "";
|
||||
qrConfig.value = null;
|
||||
qrError.value = "";
|
||||
try {
|
||||
const form = parseTomlToForm(await getConfig(fileName));
|
||||
const payload = buildNetworkQrPayload(form);
|
||||
qrConfig.value = payload;
|
||||
qrImage.value = await QRCode.toDataURL(JSON.stringify(payload), {
|
||||
errorCorrectionLevel: "M",
|
||||
width: 360,
|
||||
margin: 2,
|
||||
color: { dark: "#020617", light: "#ffffff" },
|
||||
});
|
||||
} catch (e) {
|
||||
qrError.value = e.message || "二维码生成失败";
|
||||
} finally {
|
||||
qrLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (fileName) => {
|
||||
const ok = await ui.confirm({
|
||||
title: "删除配置",
|
||||
message: `确定要删除配置 ${fileName} 吗?`,
|
||||
danger: true,
|
||||
confirmText: "删除",
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await deleteConfig(fileName);
|
||||
ui.toast.success("配置已删除");
|
||||
app.fetchConfigList();
|
||||
} catch (e) {
|
||||
ui.toast.error(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => app.fetchConfigList());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="page-title">配置</h1>
|
||||
<p class="page-subtitle">管理组网配置文件</p>
|
||||
</div>
|
||||
<button class="btn-primary" @click="openEditor(null)">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
新建配置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<EmptyState v-if="app.configList.length === 0" text="暂无配置,点击右上角新建" />
|
||||
|
||||
<div v-else class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div
|
||||
v-for="cfg in app.configList"
|
||||
:key="cfg.file_name"
|
||||
:class="cardClass(cfg.file_name)"
|
||||
class="card group relative flex min-h-[140px] cursor-pointer flex-col justify-between overflow-hidden"
|
||||
@click="openEditor(cfg.file_name)"
|
||||
>
|
||||
<button
|
||||
class="absolute right-3 top-3 z-10 rounded-lg p-1.5 text-slate-400 transition hover:bg-indigo-50 hover:text-indigo-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:hover:bg-indigo-500/10 dark:hover:text-indigo-400"
|
||||
type="button"
|
||||
title="显示加入网络二维码"
|
||||
aria-label="显示加入网络二维码"
|
||||
@click.stop="openQr(cfg.file_name)"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h7v7H3V3zm11 0h7v7h-7V3zM3 14h7v7H3v-7zm12 0h2m4 0v2m-6 2h2v3m4-3v3h-2" />
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
v-if="instStatus(cfg.file_name) === 'running'"
|
||||
class="absolute right-12 top-3 rounded bg-green-500 px-2 py-1 text-xs text-white"
|
||||
>
|
||||
运行中
|
||||
</div>
|
||||
<div
|
||||
v-else-if="instStatus(cfg.file_name) === 'starting'"
|
||||
class="absolute right-12 top-3 rounded bg-indigo-500 px-2 py-1 text-xs text-white"
|
||||
>
|
||||
启动中
|
||||
</div>
|
||||
<div class="flex items-start pr-28">
|
||||
<div class="mr-3 mt-1 rounded-lg bg-indigo-50 p-2 text-indigo-600 dark:bg-indigo-500/10 dark:text-indigo-400">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="overflow-hidden">
|
||||
<h3 class="truncate text-lg font-bold text-slate-900 dark:text-white" :title="cfg.config_name">
|
||||
{{ cfg.config_name || "Unnamed" }}
|
||||
</h3>
|
||||
<p class="truncate font-mono text-xs text-slate-400" :title="cfg.file_name">
|
||||
{{ cfg.file_name }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-card-actions mt-4 flex justify-end transition-opacity">
|
||||
<button
|
||||
class="mr-4 text-sm text-indigo-600 hover:text-indigo-500 dark:text-indigo-400"
|
||||
@click.stop="openEditor(cfg.file_name)"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button class="text-sm text-red-500 hover:text-red-400" @click.stop="handleDelete(cfg.file_name)">
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfigEditor
|
||||
:show="showEditor"
|
||||
:file-name="editorFileName"
|
||||
@close="showEditor = false"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
|
||||
<AppModal :show="showQr" panel-class="w-full max-w-md" @close="showQr = false">
|
||||
<template #header>
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-slate-900 dark:text-white">扫码加入网络</h2>
|
||||
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">使用 VNT 安卓客户端扫描</p>
|
||||
</div>
|
||||
<button class="btn-ghost btn-sm" type="button" @click="showQr = false">关闭</button>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="p-6">
|
||||
<div v-if="qrLoading" class="py-20 text-center text-sm muted">正在生成二维码…</div>
|
||||
<div v-else-if="qrError" class="rounded-lg bg-red-50 p-4 text-sm text-red-600 dark:bg-red-500/10 dark:text-red-400">
|
||||
{{ qrError }}
|
||||
</div>
|
||||
<div v-else-if="qrConfig" class="space-y-5">
|
||||
<div class="mx-auto w-fit rounded-xl border border-slate-200 bg-white p-3 shadow-sm">
|
||||
<img :src="qrImage" class="h-auto w-full max-w-[320px]" alt="VNT 加入网络二维码" />
|
||||
</div>
|
||||
<dl class="space-y-2 rounded-lg bg-slate-50 p-4 text-sm dark:bg-slate-800/60">
|
||||
<div class="flex gap-3"><dt class="w-20 shrink-0 muted">组网编号</dt><dd class="min-w-0 break-all font-mono text-slate-900 dark:text-white">{{ qrConfig.network_code }}</dd></div>
|
||||
<div class="flex gap-3"><dt class="w-20 shrink-0 muted">服务器</dt><dd class="min-w-0 break-all font-mono text-slate-900 dark:text-white">{{ qrConfig.server.join(", ") }}</dd></div>
|
||||
<div class="flex gap-3"><dt class="w-20 shrink-0 muted">MTU</dt><dd class="font-mono text-slate-900 dark:text-white">{{ qrConfig.mtu }}</dd></div>
|
||||
<div class="flex gap-3"><dt class="w-20 shrink-0 muted">加密密码</dt><dd class="text-slate-900 dark:text-white">{{ qrConfig.password ? "已包含" : "未设置" }}</dd></div>
|
||||
</dl>
|
||||
<p class="text-xs leading-5 text-amber-600 dark:text-amber-400">二维码包含组网凭据,请仅分享给可信设备。</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</AppModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 触屏和窄屏设备没有可靠的 hover,操作按钮必须直接可见。 */
|
||||
.config-card-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 只有宽屏且确实支持精细悬停的设备才使用移入显示。 */
|
||||
@media (min-width: 640px) and (hover: hover) and (pointer: fine) {
|
||||
.config-card-actions {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.group:hover .config-card-actions,
|
||||
.group:focus-within .config-card-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,227 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { useAppStore } from "../stores/app";
|
||||
import StartPanel from "../components/StartPanel.vue";
|
||||
import InstanceCard from "../components/InstanceCard.vue";
|
||||
import EmptyState from "../components/EmptyState.vue";
|
||||
|
||||
const app = useAppStore();
|
||||
|
||||
// 在线设备数:汇总各 running 实例 info 的在线数
|
||||
const onlineDevices = computed(() =>
|
||||
Object.values(app.instances).reduce(
|
||||
(sum, info) => sum + (info?.online_client_num || 0),
|
||||
0,
|
||||
),
|
||||
);
|
||||
|
||||
// 服务器连接:已连接的 running 实例数 / running 总数
|
||||
const serverConnected = computed(() => {
|
||||
const running = app.instanceList.filter((i) => i.status === "running");
|
||||
const connected = running.filter((i) => {
|
||||
const info = app.instances[i.file_name];
|
||||
return info?.server_info?.some((s) => s.connected);
|
||||
});
|
||||
return { connected: connected.length, total: running.length };
|
||||
});
|
||||
|
||||
const statusSummary = computed(() => {
|
||||
if (app.runningCount > 0) return `运行中 ${app.runningCount} 个实例`;
|
||||
if (app.startingCount > 0) return "有实例正在启动...";
|
||||
return "全部实例已停止";
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- 欢迎/状态区 -->
|
||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="page-title">总览</h1>
|
||||
<p class="page-subtitle">{{ statusSummary }}</p>
|
||||
</div>
|
||||
<router-link to="/config" class="btn-ghost btn-sm">管理配置</router-link>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<div class="card">
|
||||
<div class="text-xs font-medium muted">运行实例</div>
|
||||
<div class="mt-2 text-2xl font-bold tabular-nums text-slate-900 dark:text-white">
|
||||
{{ app.runningCount
|
||||
}}<span class="ml-1 text-sm font-normal muted">/ {{ app.instanceList.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="text-xs font-medium muted">配置总数</div>
|
||||
<div class="mt-2 text-2xl font-bold tabular-nums text-slate-900 dark:text-white">
|
||||
{{ app.configList.length }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="text-xs font-medium muted">在线设备</div>
|
||||
<div
|
||||
class="mt-2 text-2xl font-bold tabular-nums"
|
||||
:class="onlineDevices > 0 ? 'text-green-600 dark:text-green-400' : 'text-slate-900 dark:text-white'"
|
||||
>
|
||||
{{ onlineDevices }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="text-xs font-medium muted">服务器连接</div>
|
||||
<div class="mt-2 text-2xl font-bold tabular-nums text-slate-900 dark:text-white">
|
||||
<template v-if="serverConnected.total > 0">
|
||||
<span :class="serverConnected.connected > 0 ? 'text-green-600 dark:text-green-400' : 'text-red-500'">{{
|
||||
serverConnected.connected
|
||||
}}</span>
|
||||
<span class="text-sm font-normal muted">/ {{ serverConnected.total }} 已连接</span>
|
||||
</template>
|
||||
<span v-else class="muted">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 启动组网 -->
|
||||
<StartPanel />
|
||||
|
||||
<!-- 实例列表 -->
|
||||
<div>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h2 class="text-base font-bold text-slate-900 dark:text-white">组网实例</h2>
|
||||
<span v-if="app.instanceList.length > 0" class="text-xs muted">点击卡片查看详情</span>
|
||||
</div>
|
||||
<EmptyState
|
||||
v-if="app.instanceList.length === 0"
|
||||
:text="app.configList.length === 0 ? '暂无配置,请先新建配置' : '暂无运行中的组网,请在上方选择配置启动'"
|
||||
/>
|
||||
<TransitionGroup v-else name="card-list" tag="div" class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InstanceCard v-for="inst in app.instanceList" :key="inst.file_name" :inst="inst" selectable />
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
|
||||
<!-- 选中实例详情 -->
|
||||
<template v-if="app.selectedInstance && app.selectedInfo">
|
||||
<div class="card">
|
||||
<h2 class="mb-4 text-lg font-bold text-slate-900 dark:text-white">
|
||||
网络详情
|
||||
<span class="ml-2 text-sm font-medium text-indigo-600 dark:text-indigo-400">{{
|
||||
app.selectedConfigName
|
||||
}}</span>
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 gap-4 text-sm md:grid-cols-2">
|
||||
<div class="flex justify-between border-b border-slate-100 pb-2 dark:border-slate-700">
|
||||
<span class="muted">虚拟 IP / 掩码</span>
|
||||
<span class="font-mono tabular-nums text-slate-900 dark:text-white"
|
||||
>{{ app.selectedInfo.ip || "-" }} / {{ app.selectedInfo.prefix_len || "-" }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between border-b border-slate-100 pb-2 dark:border-slate-700">
|
||||
<span class="muted">网关</span>
|
||||
<span class="font-mono text-slate-900 dark:text-white">{{ app.selectedInfo.gateway || "-" }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between border-b border-slate-100 pb-2 dark:border-slate-700">
|
||||
<span class="muted">网络编号</span>
|
||||
<span class="font-mono text-slate-900 dark:text-white">{{ app.selectedInfo.network_code || "-" }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between border-b border-slate-100 pb-2 dark:border-slate-700">
|
||||
<span class="muted">MTU</span>
|
||||
<span class="font-mono tabular-nums text-slate-900 dark:text-white">{{ app.selectedInfo.mtu || "" }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between border-b border-slate-100 pb-2 dark:border-slate-700">
|
||||
<span class="muted">NAT 类型</span>
|
||||
<span class="font-mono text-indigo-600 dark:text-indigo-400">{{
|
||||
app.selectedInfo.nat_type || "Unknown"
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex justify-between border-b border-slate-100 pb-2 dark:border-slate-700">
|
||||
<span class="muted">Public IPv6</span>
|
||||
<span
|
||||
class="max-w-[200px] truncate font-mono text-slate-900 dark:text-white"
|
||||
:title="app.selectedInfo.public_ipv6"
|
||||
>{{ app.selectedInfo.public_ipv6 || "-" }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between border-b border-slate-100 pb-2 dark:border-slate-700">
|
||||
<span class="muted">设备名称</span>
|
||||
<span class="text-slate-900 dark:text-white">{{ app.selectedInfo.name || "-" }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between border-b border-slate-100 pb-2 dark:border-slate-700">
|
||||
<span class="muted">设备 ID</span>
|
||||
<span
|
||||
class="max-w-[200px] truncate font-mono text-xs text-slate-500 dark:text-slate-400"
|
||||
:title="app.selectedInfo.device_id"
|
||||
>{{ app.selectedInfo.device_id || "-" }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<div
|
||||
v-for="feat in [
|
||||
{ key: 'encrypt', label: '加密' },
|
||||
{ key: 'compress', label: '压缩' },
|
||||
{ key: 'fec', label: 'FEC纠错' },
|
||||
{ key: 'rtx', label: 'QUIC传输' },
|
||||
]"
|
||||
:key="feat.key"
|
||||
class="flex items-center gap-2 rounded-lg bg-slate-50 px-3 py-2 dark:bg-slate-800/50"
|
||||
>
|
||||
<span
|
||||
class="h-2 w-2 rounded-full"
|
||||
:class="app.selectedInfo[feat.key] ? 'bg-green-500' : 'bg-slate-300 dark:bg-slate-600'"
|
||||
></span>
|
||||
<span class="text-sm text-slate-600 dark:text-slate-300">{{ feat.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<span class="mb-2 block text-sm muted">Public IPv4s</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="pip in app.selectedInfo.public_ipv4s"
|
||||
:key="pip"
|
||||
class="rounded border border-slate-200 bg-slate-50 px-2 py-1 font-mono text-xs tabular-nums text-green-700 dark:border-slate-600 dark:bg-slate-800 dark:text-green-300"
|
||||
>{{ pip }}</span
|
||||
>
|
||||
<span
|
||||
v-if="!app.selectedInfo.public_ipv4s || app.selectedInfo.public_ipv4s.length === 0"
|
||||
class="text-xs text-slate-400"
|
||||
>无</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-0 overflow-hidden">
|
||||
<div class="border-b border-slate-200 px-6 py-4 dark:border-slate-700">
|
||||
<h2 class="text-lg font-bold text-slate-900 dark:text-white">服务器连接列表</h2>
|
||||
</div>
|
||||
<div class="custom-scrollbar max-h-[400px] overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>地址</th>
|
||||
<th>状态</th>
|
||||
<th>延迟</th>
|
||||
<th>版本</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(server, idx) in app.selectedInfo.server_info" :key="idx">
|
||||
<td class="font-mono">{{ server.server }}</td>
|
||||
<td>
|
||||
<span :class="server.connected ? 'badge-green' : 'badge-red'">
|
||||
{{ server.connected ? "已连接" : "未连接" }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="tabular-nums">{{ server.server_rtt ? server.server_rtt + " ms" : "-" }}</td>
|
||||
<td>{{ server.server_version || "-" }}</td>
|
||||
</tr>
|
||||
<tr v-if="!app.selectedInfo.server_info || app.selectedInfo.server_info.length === 0">
|
||||
<td colspan="4" class="text-center text-slate-400">暂无数据</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,336 @@
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, onUnmounted, watch, inject } from "vue";
|
||||
import { useAppStore } from "../stores/app";
|
||||
import { getPeers } from "../api";
|
||||
import { formatTime, formatBytes, formatSpeed } from "../utils/format";
|
||||
import StatusDot from "../components/StatusDot.vue";
|
||||
import EmptyState from "../components/EmptyState.vue";
|
||||
import SpeedChart from "../components/SpeedChart.vue";
|
||||
|
||||
const app = useAppStore();
|
||||
const tooltipRef = inject("peerTooltip");
|
||||
const showPeerTooltip = (e, peer) => tooltipRef.value?.showPeerTooltip(e, peer);
|
||||
const hidePeerTooltip = () => tooltipRef.value?.hidePeerTooltip();
|
||||
|
||||
const peers = ref([]);
|
||||
let timer = null;
|
||||
// 记录上次流量数据和时间,用于前端计算网速
|
||||
let lastTrafficMap = {};
|
||||
let lastFetchTime = 0;
|
||||
// 展开状态和网速历史
|
||||
const expandedPeers = reactive({});
|
||||
const speedHistoryMap = reactive({});
|
||||
const HISTORY_SIZE = 60;
|
||||
|
||||
const toggleExpand = (ip) => {
|
||||
expandedPeers[ip] = !expandedPeers[ip];
|
||||
};
|
||||
|
||||
const currentStatus = () => {
|
||||
const inst = app.instanceList.find((i) => i.file_name === app.selectedInstance);
|
||||
return inst ? inst.status : null;
|
||||
};
|
||||
|
||||
const resetPeerState = () => {
|
||||
peers.value = [];
|
||||
lastTrafficMap = {};
|
||||
lastFetchTime = 0;
|
||||
for (const key in speedHistoryMap) delete speedHistoryMap[key];
|
||||
for (const key in expandedPeers) delete expandedPeers[key];
|
||||
};
|
||||
|
||||
const fetchPeers = async () => {
|
||||
if (!app.selectedInstance || currentStatus() !== "running") {
|
||||
resetPeerState();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const list = (await getPeers(app.selectedInstance)) || [];
|
||||
const now = Date.now();
|
||||
const elapsed = lastFetchTime > 0 ? (now - lastFetchTime) / 1000 : 0;
|
||||
const newTrafficMap = {};
|
||||
for (const peer of list) {
|
||||
if (peer.traffic) {
|
||||
const key = peer.ip;
|
||||
const prev = lastTrafficMap[key];
|
||||
if (prev && elapsed > 0) {
|
||||
const txDiff = Math.max(0, peer.traffic.tx_bytes - prev.tx_bytes);
|
||||
const rxDiff = Math.max(0, peer.traffic.rx_bytes - prev.rx_bytes);
|
||||
peer.traffic.tx_speed = Math.round(txDiff / elapsed);
|
||||
peer.traffic.rx_speed = Math.round(rxDiff / elapsed);
|
||||
} else {
|
||||
peer.traffic.tx_speed = 0;
|
||||
peer.traffic.rx_speed = 0;
|
||||
}
|
||||
newTrafficMap[key] = { tx_bytes: peer.traffic.tx_bytes, rx_bytes: peer.traffic.rx_bytes };
|
||||
// 记录速度历史
|
||||
if (!speedHistoryMap[key]) speedHistoryMap[key] = { tx: [], rx: [] };
|
||||
speedHistoryMap[key].tx.push(peer.traffic.tx_speed);
|
||||
speedHistoryMap[key].rx.push(peer.traffic.rx_speed);
|
||||
if (speedHistoryMap[key].tx.length > HISTORY_SIZE) {
|
||||
speedHistoryMap[key].tx.shift();
|
||||
speedHistoryMap[key].rx.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
lastTrafficMap = newTrafficMap;
|
||||
lastFetchTime = now;
|
||||
peers.value = list;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchPeers();
|
||||
timer = setInterval(() => {
|
||||
if (app.isPageVisible) fetchPeers();
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer);
|
||||
});
|
||||
|
||||
// 切换实例时重置并重新拉取
|
||||
watch(
|
||||
() => app.selectedInstance,
|
||||
() => {
|
||||
resetPeerState();
|
||||
fetchPeers();
|
||||
},
|
||||
);
|
||||
|
||||
// 监听选中实例状态变化,当变为 running 时立即获取数据
|
||||
watch(currentStatus, (newStatus) => {
|
||||
if (newStatus === "running") {
|
||||
fetchPeers();
|
||||
}
|
||||
});
|
||||
|
||||
const getRouteModeClass = (route) => {
|
||||
// 判断是否直连:metric === 1
|
||||
const isDirect = route.metric === 1;
|
||||
if (isDirect) {
|
||||
return "font-medium text-green-600 dark:text-green-400";
|
||||
} else {
|
||||
return "text-blue-600 dark:text-blue-400";
|
||||
}
|
||||
};
|
||||
|
||||
const getRouteModeText = (route) => {
|
||||
const isDirect = route.metric === 1;
|
||||
const isTcp = route.protocol.includes("Tcp");
|
||||
if (isDirect) {
|
||||
return isTcp ? "打洞TCP直连" : "打洞UDP直连";
|
||||
} else {
|
||||
return isTcp ? "客户端TCP中继" : "客户端UDP中继";
|
||||
}
|
||||
};
|
||||
|
||||
const keyEqualText = (keyEqual) =>
|
||||
keyEqual === 3
|
||||
? "己方加密对方未加密"
|
||||
: keyEqual === 4
|
||||
? "己方未加密对方加密"
|
||||
: keyEqual === 5
|
||||
? "密钥不一致"
|
||||
: "未知错误";
|
||||
|
||||
const switcherClass = (fileName) =>
|
||||
app.selectedInstance === fileName
|
||||
? "border-indigo-600 bg-indigo-600 text-white dark:border-indigo-500 dark:bg-indigo-500"
|
||||
: "border-slate-300 bg-white text-slate-600 hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h1 class="page-title">设备列表</h1>
|
||||
<p class="page-subtitle">查看各实例的设备连接与流量状况</p>
|
||||
</div>
|
||||
|
||||
<!-- 实例切换器 -->
|
||||
<div v-if="app.instanceList.length > 0" class="scrollbar-hide flex items-center gap-2 overflow-x-auto">
|
||||
<button
|
||||
v-for="inst in app.instanceList"
|
||||
:key="inst.file_name"
|
||||
@click="app.selectedInstance = inst.file_name"
|
||||
:class="switcherClass(inst.file_name)"
|
||||
class="flex shrink-0 items-center rounded-lg border px-4 py-2 text-sm font-medium transition-colors"
|
||||
>
|
||||
<StatusDot :status="inst.status" size="w-2 h-2" class="mr-2" />
|
||||
{{ inst.config_name || inst.file_name }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<EmptyState v-if="!app.selectedInstance" text="暂无运行中的组网实例" />
|
||||
|
||||
<div v-else class="card overflow-hidden p-0">
|
||||
<div class="flex items-center justify-between border-b border-slate-200 px-6 py-4 dark:border-slate-700">
|
||||
<h2 class="text-base font-bold text-slate-900 dark:text-white">设备列表</h2>
|
||||
<div class="flex gap-4 text-sm muted">
|
||||
<span>
|
||||
Online:
|
||||
<span class="font-medium tabular-nums text-slate-900 dark:text-white">{{
|
||||
peers.filter((p) => p.online).length
|
||||
}}</span>
|
||||
</span>
|
||||
<span>
|
||||
Total:
|
||||
<span class="font-medium tabular-nums text-slate-900 dark:text-white">{{ peers.length }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="custom-scrollbar max-h-[600px] overflow-x-auto">
|
||||
<table class="table peer-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-8 px-2"></th>
|
||||
<th>IP地址</th>
|
||||
<th>名称</th>
|
||||
<th class="hidden md:table-cell">版本</th>
|
||||
<th>状态</th>
|
||||
<th>模式</th>
|
||||
<th>延迟</th>
|
||||
<th>丢包率</th>
|
||||
<th>流量</th>
|
||||
<th class="hidden md:table-cell">最后在线</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="peer in peers" :key="peer.ip">
|
||||
<tr class="transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/50">
|
||||
<td class="cursor-pointer select-none px-2 text-center" @click="toggleExpand(peer.ip)">
|
||||
<svg
|
||||
class="inline-block h-4 w-4 text-slate-400 transition-transform duration-200"
|
||||
:class="{ 'rotate-90': expandedPeers[peer.ip] }"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</td>
|
||||
<td class="font-mono tabular-nums text-indigo-600 dark:text-indigo-400">
|
||||
<span
|
||||
class="cursor-help border-b border-dotted border-indigo-400/50 pb-0.5"
|
||||
@mouseenter="showPeerTooltip($event, peer)"
|
||||
@mouseleave="hidePeerTooltip"
|
||||
>
|
||||
{{ peer.ip }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ peer.name || "-" }}</td>
|
||||
<td class="hidden text-xs text-slate-400 md:table-cell">{{ peer.version || "-" }}</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<span :class="peer.online ? 'badge-green' : 'badge-gray'">{{
|
||||
peer.online ? "在线" : "离线"
|
||||
}}</span>
|
||||
<!-- 加密状态图标 -->
|
||||
<div v-if="peer.online && peer.key_equal === 1" class="tooltip">
|
||||
<svg class="h-4 w-4 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="tooltip-text">双方加密传输</span>
|
||||
</div>
|
||||
<div v-else-if="peer.online && peer.key_equal === 2" class="tooltip">
|
||||
<svg class="h-4 w-4 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="tooltip-text">双方未加密</span>
|
||||
</div>
|
||||
<div v-else-if="peer.online && [3, 4, 5].includes(peer.key_equal)" class="tooltip cursor-help">
|
||||
<svg class="h-4 w-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="tooltip-text">{{ keyEqualText(peer.key_equal) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="peer.online && peer.route" :class="getRouteModeClass(peer.route)">{{
|
||||
getRouteModeText(peer.route)
|
||||
}}</span>
|
||||
<span v-else-if="peer.online" class="text-yellow-600 dark:text-yellow-400">服务器中继</span>
|
||||
<span v-else class="text-slate-400">-</span>
|
||||
</td>
|
||||
<td class="tabular-nums">{{ peer.route ? peer.route.rtt + " ms" : "-" }}</td>
|
||||
<td>
|
||||
<span
|
||||
v-if="peer.packet_loss"
|
||||
:class="
|
||||
peer.packet_loss.loss_rate > 10
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: peer.packet_loss.loss_rate > 5
|
||||
? 'text-yellow-600 dark:text-yellow-400'
|
||||
: 'text-green-600 dark:text-green-400'
|
||||
"
|
||||
:title="'Sent: ' + peer.packet_loss.sent + ', Received: ' + peer.packet_loss.received"
|
||||
>{{ peer.packet_loss.loss_rate.toFixed(1) }}%</span
|
||||
>
|
||||
<span v-else class="text-slate-400">-</span>
|
||||
</td>
|
||||
<td class="text-xs">
|
||||
<div v-if="peer.traffic" class="leading-relaxed">
|
||||
<div class="text-green-600 tabular-nums dark:text-green-400">
|
||||
↑ {{ formatBytes(peer.traffic.tx_bytes) }} ({{ formatSpeed(peer.traffic.tx_speed) }})
|
||||
</div>
|
||||
<div class="text-blue-600 tabular-nums dark:text-blue-400">
|
||||
↓ {{ formatBytes(peer.traffic.rx_bytes) }} ({{ formatSpeed(peer.traffic.rx_speed) }})
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-slate-400">-</span>
|
||||
</td>
|
||||
<td class="hidden font-mono text-xs text-slate-400 md:table-cell">
|
||||
{{ formatTime(peer.last_connected_time) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="expandedPeers[peer.ip]">
|
||||
<td colspan="10" class="p-0">
|
||||
<div class="border-t border-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-700/50 dark:bg-slate-950/60">
|
||||
<SpeedChart :history="speedHistoryMap[peer.ip] || { tx: [], rx: [] }" :size="60" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.peer-table :deep(thead th) {
|
||||
padding: 0.5rem;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
.peer-table :deep(tbody td) {
|
||||
padding: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.peer-table :deep(th:first-child),
|
||||
.peer-table :deep(td:first-child) {
|
||||
padding-left: 0.25rem;
|
||||
padding-right: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,122 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch } from "vue";
|
||||
import { useAppStore } from "../stores/app";
|
||||
import { getRoutes } from "../api";
|
||||
import StatusDot from "../components/StatusDot.vue";
|
||||
import EmptyState from "../components/EmptyState.vue";
|
||||
|
||||
const app = useAppStore();
|
||||
const routes = ref([]);
|
||||
let timer = null;
|
||||
|
||||
const currentStatus = () => {
|
||||
const inst = app.instanceList.find((i) => i.file_name === app.selectedInstance);
|
||||
return inst ? inst.status : null;
|
||||
};
|
||||
|
||||
const fetchRoutes = async () => {
|
||||
if (!app.selectedInstance || currentStatus() !== "running") {
|
||||
routes.value = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
routes.value = (await getRoutes(app.selectedInstance)) || [];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchRoutes();
|
||||
timer = setInterval(() => {
|
||||
if (app.isPageVisible) fetchRoutes();
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer);
|
||||
});
|
||||
|
||||
// 切换实例时重新拉取
|
||||
watch(
|
||||
() => app.selectedInstance,
|
||||
() => {
|
||||
fetchRoutes();
|
||||
},
|
||||
);
|
||||
|
||||
// 监听选中实例状态变化,当变为 running 时立即获取数据
|
||||
watch(currentStatus, (newStatus) => {
|
||||
if (newStatus === "running") {
|
||||
fetchRoutes();
|
||||
}
|
||||
});
|
||||
|
||||
const switcherClass = (fileName) =>
|
||||
app.selectedInstance === fileName
|
||||
? "border-indigo-600 bg-indigo-600 text-white dark:border-indigo-500 dark:bg-indigo-500"
|
||||
: "border-slate-300 bg-white text-slate-600 hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h1 class="page-title">路由</h1>
|
||||
<p class="page-subtitle">查看各实例的路由表</p>
|
||||
</div>
|
||||
|
||||
<!-- 实例切换器 -->
|
||||
<div v-if="app.instanceList.length > 0" class="scrollbar-hide flex items-center gap-2 overflow-x-auto">
|
||||
<button
|
||||
v-for="inst in app.instanceList"
|
||||
:key="inst.file_name"
|
||||
@click="app.selectedInstance = inst.file_name"
|
||||
:class="switcherClass(inst.file_name)"
|
||||
class="flex shrink-0 items-center rounded-lg border px-4 py-2 text-sm font-medium transition-colors"
|
||||
>
|
||||
<StatusDot :status="inst.status" size="w-2 h-2" class="mr-2" />
|
||||
{{ inst.config_name || inst.file_name }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<EmptyState v-if="!app.selectedInstance" text="暂无运行中的组网实例" />
|
||||
|
||||
<div v-else class="card overflow-hidden p-0">
|
||||
<div class="border-b border-slate-200 px-6 py-4 dark:border-slate-700">
|
||||
<h2 class="text-base font-bold text-slate-900 dark:text-white">路由表</h2>
|
||||
</div>
|
||||
<div class="custom-scrollbar max-h-[600px] overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>目标节点IP</th>
|
||||
<th>目标网络</th>
|
||||
<th>跳数</th>
|
||||
<th>延迟</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="item in routes" :key="item.ip">
|
||||
<tr
|
||||
v-for="(route, rIdx) in item.routes"
|
||||
:key="rIdx"
|
||||
class="hover:bg-slate-50 dark:hover:bg-slate-800/50"
|
||||
>
|
||||
<td
|
||||
v-if="rIdx === 0"
|
||||
:rowspan="item.routes.length"
|
||||
class="font-mono tabular-nums text-indigo-600 dark:text-indigo-400"
|
||||
>
|
||||
{{ item.ip }}
|
||||
</td>
|
||||
<td class="font-mono tabular-nums text-yellow-700 dark:text-yellow-300">{{ route.addr }}</td>
|
||||
<td class="tabular-nums">{{ route.metric }}</td>
|
||||
<td class="tabular-nums">{{ route.rtt }} ms</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,215 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import AppSelect from "../components/AppSelect.vue";
|
||||
|
||||
const bridge = globalThis.__VNT_WEB_ACCESS__;
|
||||
const draft = reactive({ enabled: false, port: 19099, global: false, token: "" });
|
||||
const status = ref(null);
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
const notice = ref("");
|
||||
const error = ref("");
|
||||
const listenScopeOptions = [
|
||||
{ value: false, label: "仅本机(推荐)" },
|
||||
{ value: true, label: "局域网内所有设备" },
|
||||
];
|
||||
|
||||
const sync = (value) => {
|
||||
status.value = value;
|
||||
Object.assign(draft, {
|
||||
enabled: value.enabled,
|
||||
port: value.port,
|
||||
global: value.global,
|
||||
token: value.token,
|
||||
});
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
if (!bridge) throw new Error("Web 访问设置仅在桌面客户端中提供");
|
||||
sync(await bridge.status());
|
||||
} catch (err) {
|
||||
error.value = err.message || String(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const update = async (changes, successMessage) => {
|
||||
saving.value = true;
|
||||
error.value = "";
|
||||
notice.value = "";
|
||||
try {
|
||||
sync(await bridge.update({
|
||||
...draft,
|
||||
...changes,
|
||||
port: Number(changes.port ?? draft.port),
|
||||
}));
|
||||
notice.value = successMessage;
|
||||
} catch (err) {
|
||||
const message = err.message || String(err);
|
||||
try {
|
||||
sync(await bridge.status());
|
||||
} catch {
|
||||
// 保留原始操作错误,状态刷新失败不覆盖它。
|
||||
}
|
||||
error.value = message;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleService = async () => {
|
||||
const enabled = !draft.enabled;
|
||||
await update(
|
||||
{ enabled },
|
||||
enabled ? "Web 服务已启动" : "Web 服务已关闭",
|
||||
);
|
||||
};
|
||||
|
||||
const regenerate = async () => {
|
||||
const token = await bridge.generateToken();
|
||||
await update(
|
||||
{ token },
|
||||
draft.enabled ? "新令牌已生效,Web 服务已重新加载" : "新令牌已生成",
|
||||
);
|
||||
};
|
||||
|
||||
const saveNetworkSettings = async () => {
|
||||
if (draft.enabled) return;
|
||||
await update({}, "监听设置已自动保存");
|
||||
};
|
||||
|
||||
const updateListenScope = async (value) => {
|
||||
draft.global = value;
|
||||
await saveNetworkSettings();
|
||||
};
|
||||
|
||||
const copyUrl = async () => {
|
||||
await navigator.clipboard.writeText(status.value.url);
|
||||
notice.value = "访问地址已复制";
|
||||
};
|
||||
|
||||
const openBrowser = async () => {
|
||||
await bridge.openUrl(status.value.url);
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-4xl space-y-5">
|
||||
<div class="page-title">
|
||||
<div>
|
||||
<h2>Web 访问</h2>
|
||||
<p>从浏览器访问当前 VNT 进程,API 请求由持久访问令牌保护。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="card text-sm text-slate-400">正在读取 Web 服务状态…</div>
|
||||
<div v-else-if="!bridge" class="card border-red-200 text-sm text-red-600 dark:border-red-900 dark:text-red-300">{{ error }}</div>
|
||||
<template v-else>
|
||||
<section class="card space-y-6">
|
||||
<div class="flex items-start justify-between gap-5">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-slate-900 dark:text-white">启用 Web 服务</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-label="启用 Web 服务"
|
||||
:aria-checked="draft.enabled"
|
||||
:disabled="saving"
|
||||
class="web-switch transition-colors disabled:opacity-50"
|
||||
:class="draft.enabled ? 'bg-indigo-600 dark:bg-indigo-500' : 'bg-slate-300 dark:bg-slate-600'"
|
||||
@click="toggleService"
|
||||
>
|
||||
<span class="web-switch-knob bg-white shadow-sm" :class="{ 'web-switch-knob-on': draft.enabled }"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-5 sm:grid-cols-2">
|
||||
<label class="block">
|
||||
<span class="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-200">监听端口</span>
|
||||
<input v-model.number="draft.port" class="input font-mono" type="number" min="1" max="65535" :disabled="saving || draft.enabled" @change="saveNetworkSettings" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-200">监听范围</span>
|
||||
<AppSelect
|
||||
:model-value="draft.global"
|
||||
:options="listenScopeOptions"
|
||||
:disabled="saving || draft.enabled"
|
||||
aria-label="监听范围"
|
||||
@update:model-value="updateListenScope"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p class="-mt-3 text-xs text-slate-400">端口和监听范围会自动保存;需要修改时请先关闭 Web 服务。</p>
|
||||
|
||||
<div>
|
||||
<div class="mb-2 flex items-center justify-between gap-3">
|
||||
<span class="text-sm font-medium text-slate-700 dark:text-slate-200">访问令牌</span>
|
||||
<button class="text-xs font-medium text-indigo-600 hover:text-indigo-500 disabled:opacity-50 dark:text-indigo-400" type="button" :disabled="saving" @click="regenerate">更换令牌</button>
|
||||
</div>
|
||||
<code class="block min-w-0 truncate rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-xs text-slate-600 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-300">{{ draft.token }}</code>
|
||||
<p class="mt-2 text-xs text-slate-400">更换令牌后,已登录的浏览器需要使用新令牌重新鉴权。</p>
|
||||
</div>
|
||||
|
||||
<div v-if="notice || error" class="border-t border-slate-200 pt-5 dark:border-slate-800">
|
||||
<span v-if="notice" class="text-xs text-green-600 dark:text-green-400">{{ notice }}</span>
|
||||
<span v-if="error" class="text-xs text-red-600 dark:text-red-400">{{ error }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="mt-1 h-2.5 w-2.5 shrink-0 rounded-full" :class="status?.running ? 'bg-green-500' : 'bg-slate-300 dark:bg-slate-600'"></span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-semibold text-slate-900 dark:text-white">{{ status?.running ? "运行中" : "未运行" }}</div>
|
||||
<div class="mt-1 text-xs text-slate-400">监听地址:{{ status?.listenAddress }}</div>
|
||||
<div v-if="status?.running" class="mt-4 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 font-mono text-xs text-slate-600 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-300">
|
||||
<span class="block truncate">{{ status.url }}</span>
|
||||
</div>
|
||||
<div v-if="status?.running" class="mt-3 flex flex-wrap gap-2">
|
||||
<button class="btn-primary btn-sm" type="button" @click="openBrowser">打开浏览器</button>
|
||||
<button class="btn-ghost btn-sm" type="button" @click="copyUrl">复制访问地址</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.web-switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex: 0 0 44px;
|
||||
align-items: center;
|
||||
width: 44px;
|
||||
min-width: 44px;
|
||||
height: 24px;
|
||||
min-height: 24px;
|
||||
padding: 2px;
|
||||
border: 0;
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.web-switch-knob {
|
||||
display: block;
|
||||
width: 20px;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 9999px;
|
||||
transform: translateX(0);
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
|
||||
.web-switch-knob-on {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue(), tailwindcss()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://127.0.0.1:19099",
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "../static",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||