Compare commits

...

70 Commits

Author SHA1 Message Date
Sijie.Sun
b44053f496 support p2p-only mode (#1598) 2025-11-20 08:20:27 +08:00
Sijie.Sun
5b9ac65477 update readme (#1594) 2025-11-17 16:00:32 +08:00
Mg Pig
d726d46a00 fix: Preserve disable_sym_hole_punching setting on edit (#1589) 2025-11-15 18:57:59 +08:00
Mg Pig
1273426009 feat: Enable core to use local config files while being managed via the web (#1540) 2025-11-08 20:32:00 +08:00
Sijie.Sun
b50744690e easytier-web and uptime use mimalloc as allocator (#1559) 2025-11-08 11:07:33 +08:00
Tunglies
55b93454dc fix: clippy errors with stable toolchain and default features (#1553)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-07 20:08:39 +08:00
Mg Pig
89cc75f674 refactor: replace ConfigSource with bool parameter (#1516) 2025-11-04 13:48:10 +08:00
Mg Pig
6bb2fd9a15 feat(core): Refactor IDN and URL handling logic (#1533)
* feat(core): Refactor IDN and URL handling logic

* feat(tests): add dual_convert option for URL serialization in IDN tests
2025-11-03 22:15:40 +08:00
Mg Pig
8ab98bba8f feat(ui): make port forward config responsive (#1530) 2025-10-31 23:23:36 +08:00
韩嘉乐
26d002bc2b The flowback solution of HarmonyOS 5 failed due to the anti-loop mechanism. (#1514) 2025-10-25 00:17:24 +08:00
Sijie.Sun
71679e889a allow sync conn with conn list when conn bitmap is too large (#1508) 2025-10-23 08:11:36 +08:00
Sijie.Sun
7485f5f64e make sure event is triggered when peer conn remove (#1507) 2025-10-22 23:37:19 +08:00
Mg Pig
bbe8f9f810 feat(ui): Display network names and optimize list loading (#1503) 2025-10-22 13:40:36 +08:00
Mg Pig
eba9504fc2 refactor(gui): refactor gui to use RemoteClient trait and RemoteManagement component (#1489)
* refactor(gui): refactor gui to use RemoteClient trait and RemoteManagement component
* feat(gui): Add network config saving and refactor RemoteManagement
2025-10-20 22:07:01 +08:00
kuaifan
67ac9b00ff feat(gui): Optimize the data table column header style to prevent line breaks (#1497) 2025-10-19 16:50:17 +08:00
Sijie.Sun
3ffa6214ca fix subnet proxy deadloop (#1492)
* use LPM to determine subnet proxy dst.
* never allow subnet proxy traffic sending to self.
2025-10-19 15:46:51 +08:00
Mg Pig
6f278ab167 chore: update flake configuration (#1490) 2025-10-19 00:25:40 +08:00
Sijie.Sun
f10b45a67c [easytier-uptime] support tag in node list (#1487) 2025-10-18 23:19:53 +08:00
Sijie.Sun
cc8f35787e release dashmap memory (#1485) 2025-10-18 12:48:04 +08:00
Sijie.Sun
8f1786fa23 replace tachyonix with tokio mpsc in MpscTunnel (#1483)
tachyonix cannot correctly wakeup senders when the receiver is closed
and causing tasks deadlock and memory leak.
2025-10-17 00:09:13 +08:00
编程小白
70dddeace3 Fix support for Chinese domain names (#1462) 2025-10-15 21:00:05 +08:00
Mg Pig
8cc9da9d6d fix(web): fix generate and parse config methods broken in #1465 (#1476) 2025-10-14 15:13:20 +08:00
Luna Yao
5292b87275 Add quic-listen-port flag for customization of the port used by QUIC proxy (#1473) 2025-10-14 09:43:50 +08:00
Mg Pig
87b7b7ed7c refactor(web): Refactor web logic to extract reusable remote client management module (#1465) 2025-10-13 23:59:46 +08:00
imdingtalk
999a486928 Improve update in installation script, decrease downtime(#1422) 2025-10-13 23:52:37 +08:00
TaurusXin
627e989faa feat: show NAT type of all nodes in GUI (#1464) 2025-10-13 11:40:57 +08:00
Mg Pig
af95312949 fix(acl): acl group cache add self group info (#1445) 2025-10-07 23:56:26 +08:00
Mg Pig
a452c34390 fix(ohrs): update collect_network_infos to use synchronous method (#1444) 2025-10-04 23:12:38 +08:00
Mg Pig
4d5330fa0a refactor: get_running_info fn replace status polling with direct calls (#1441) 2025-10-04 21:43:34 +08:00
agusti moll
5e48626cb9 add tld-dns-zone for customizing top-level domain (TLD) zone (#1436) 2025-10-04 00:18:10 +08:00
阿瓦
ad7dc3a129 use plist as macos service management config generator (#1439) 2025-10-04 00:14:45 +08:00
niuhuan
92fab5aafa feat(ohos) build har package (#1440)
Co-authored-by: niuhuan <20847533+niuhuan@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-02 22:29:49 +08:00
Mg Pig
841d525913 refactor(rpc): Centralize RPC service and unify API (#1427)
This change introduces a major refactoring of the RPC service layer to improve modularity, unify the API, and simplify the overall architecture.

Key changes:
- Replaced per-network-instance RPC services with a single global RPC server, reducing resource usage and simplifying management.
- All clients (CLI, Web UI, etc.) now interact with EasyTier core through a unified RPC entrypoint, enabling consistent authentication and control.
- RPC implementation logic has been moved to `easytier/src/rpc_service/` and organized by functionality (e.g., `instance_manage.rs`, `peer_manage.rs`, `config.rs`) for better maintainability.
- Standardized Protobuf API definitions under `easytier/src/proto/` with an `api_` prefix (e.g., `cli.proto` → `api_instance.proto`) to provide a consistent interface.
- CLI commands now require explicit `--instance-id` or `--instance-name` when multiple network instances are running; the parameter is optional when only one instance exists.

BREAKING CHANGE:  
RPC portal configuration (`rpc_portal` and `rpc_portal_whitelist`) has been removed from per-instance configs and the Web UI. The RPC listen address must now be specified globally via the `--rpc-portal` command-line flag or the `ET_RPC_PORTAL` environment variable, as there is only one RPC service for the entire application.
2025-10-02 20:30:39 +08:00
Luna Yao
d2efbbef04 refactor: change magicdns to internal redirect (#1428)
To resolve issue #1419, DNS request packets are read directly and responses are sent back internally instead of being forwarded to the listening port.

The DNS service on fake_ip (100.100.100.101) no longer supports DNS-over-TCP.

Co-authored-by: Sijie.Sun <sunsijie@buaa.edu.cn>
2025-10-02 20:19:12 +08:00
Sijie.Sun
971ef82679 fix data not encrypted when no tun is enabled (#1435) 2025-10-01 11:16:24 +08:00
Mg Pig
020bf04ec4 refactor(config): unify runtime configuration management via ConfigRpc (#1397)
* refactor(config): unify runtime configuration management via ConfigRpc
* feat(tests): add config patch test and fix problem
2025-10-01 00:32:28 +08:00
韩嘉乐
4d91582fd8 Update ohos-rs (#1434) 2025-09-30 23:51:58 +08:00
Sijie.Sun
e9b4dbce6e use cargo ndk in jni build script (#1424) 2025-09-28 23:18:51 +08:00
R0S
00fd02c739 正确的hostname 2025-09-26 21:17:14 +08:00
sijie.sun
c0d2045e52 bump version to v2.4.5 2025-09-26 00:48:10 +08:00
ThermalEng
835cd407bf Update hotspot_iprule.sh, Support subnet forward for usb shared network (#1411) 2025-09-25 16:25:53 +08:00
Sijie.Sun
f5ba5bb146 show traffic stats chart in web/gui (#1410) 2025-09-25 13:43:11 +08:00
Sijie.Sun
7a694257d9 add test for ipv6 wireguard vpn portal (#1408) 2025-09-25 08:24:56 +08:00
Sijie.Sun
67abf4446d fix socks5 panic (#1409) 2025-09-25 08:24:50 +08:00
Sijie.Sun
7035a3fef4 fix firewall rule not specify interface (#1407) 2025-09-25 00:11:26 +08:00
Sijie.Sun
4445916ba7 fix open log dir not work on gui (#1403) 2025-09-21 23:17:31 +08:00
Sijie.Sun
a102a8bfc7 fix macos bind failed when addr is v6 (#1398) 2025-09-21 21:47:03 +08:00
Sijie.Sun
c9e8c35e77 fix log dir not work; fix stun config from file not work; (#1393) 2025-09-20 00:20:08 +08:00
Sijie.Sun
1a1be8138a bump version to v2.4.4 (#1386) 2025-09-18 19:49:10 +08:00
Sijie.Sun
e06e8a9e8a allow enable log with cli, limit log size (#1384)
* impl logger rpc
* use size based appender
* add log args
2025-09-18 16:35:12 +08:00
Sijie.Sun
56fd6e4ab6 fix wireguard listener (#1382)
* listen both v4 and v6 for wireguard portal
* fix panic when getting udp local addr

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-17 23:45:05 +08:00
Sijie.Sun
215db09925 avoid packets sending to non-exist peer causing route loop (#1378) 2025-09-17 09:52:53 +08:00
fanyang
9fff5e4fec Add config validation flag (#1376)
Add `--check-config` CLI option to validate configuration without
starting network
2025-09-16 22:58:07 +08:00
Sijie.Sun
802d3f78d7 distinct v6 and v4 tunnel in gui and cli (#1373) 2025-09-16 07:24:31 +08:00
Sijie.Sun
3593035eb9 fix networksetup timeout on some machine (#1372) 2025-09-15 23:33:43 +08:00
Sijie.Sun
757d76c9da fix stun server list empty when config is from web (#1371) 2025-09-15 22:52:58 +08:00
fanyang
445e68ddd1 Read config from stdin (#1354) 2025-09-13 21:21:30 +08:00
Sijie.Sun
b540ec3f46 improve uptime (#1365) 2025-09-13 19:14:13 +08:00
Sijie.Sun
5c90431876 fix smoltcp attempt to subtract sequence numbers with underflow (#1360) 2025-09-13 15:03:04 +08:00
Sijie.Sun
793889c3b7 fix ospf ipv4 map error when ipv4 conflicted and changed (#1359) 2025-09-13 08:48:50 +08:00
Sijie.Sun
eb42086f9c set correct route policy for udp/icmp (#1361) 2025-09-13 08:48:37 +08:00
Sijie.Sun
d0efc40efb fix foreign network direct conn with mapped listeners (#1363) 2025-09-13 08:48:12 +08:00
fanyang
ae704d1d5f Fix jemalloc warning on macOS (#1344)
fix:
```
-> % easytier-core
<jemalloc>: option background_thread currently supports pthread only
```

Reference: https://github.com/apache/arrow/pull/5729
2025-09-08 21:53:40 +08:00
fanyang
525dfd9fc1 cli: improve peer table display with shorter columns for small display (#1342)
- Add short column names for latency, loss rate, rx/tx bytes, tunnel protocol and NAT type
- Format loss rate as percentage with one decimal place
- Change table style from modern to markdown for better readability
2025-09-08 21:52:53 +08:00
Sijie.Sun
18bd178bbd update readme to fix script installation command (#1341) 2025-09-06 17:02:31 +08:00
fanyang
088155f6f3 core: hide default STUN servers from cli (#1334) 2025-09-06 15:53:34 +08:00
Sijie.Sun
b750faa66f add android jni (#1340) 2025-09-06 13:49:42 +08:00
Sijie.Sun
ef3309814d fix cli add port forward failed if initial forward list is empty (#1324) 2025-09-02 22:03:57 +08:00
fanyang
b87a05b457 refactor: update custom STUN server settings (#1310)
* refactor: update global context STUN server initialization

Modified global context initialization to use a single StunInfoCollector
instance with properly configured IPv4 and IPv6 servers instead of
creating separate instances.

feat: add IPv6 STUN server configuration support

Added interface methods and config struct fields to support both IPv4
and IPv6 STUN server configuration. Modified getter and setter methods
to handle Option<Vec<String>> type for both server types.

feat: enhance StunInfoCollector with IPv6 support

Updated StunInfoCollector to support both IPv4 and IPv6 STUN servers.
Added new constructor that accepts both server types and methods to set
them independently.

feat: add CLI argument for IPv6 STUN servers

Added command line argument support for configuring IPv6 STUN servers.
Updated configuration setup to handle both IPv4 and IPv6 STUN server
settings.

docs: add localization for STUN server configuration

Added English and Chinese localization strings for the new STUN server
configuration options, including both IPv4 and IPv6 variants.
2025-09-02 21:46:37 +08:00
Joel Stodolski
754439f03c feat(gui): add macOS dock icon visibility control (#1328) 2025-09-02 17:32:18 +08:00
203 changed files with 16227 additions and 15640 deletions

View File

@@ -186,12 +186,12 @@ jobs:
fi
if [[ $OS =~ ^ubuntu.*$ && $TARGET =~ ^mips.*$ ]]; then
cargo +nightly build -r --target $TARGET -Z build-std=std,panic_abort --package=easytier --features=jemalloc
cargo +nightly-2025-09-01 build -r --target $TARGET -Z build-std=std,panic_abort --package=easytier --features=jemalloc
else
if [[ $OS =~ ^windows.*$ ]]; then
SUFFIX=.exe
CORE_FEATURES="--features=mimalloc"
elif [[ $TARGET =~ ^riscv64.*$ || $TARGET =~ ^loongarch64.*$ ]]; then
elif [[ $TARGET =~ ^riscv64.*$ || $TARGET =~ ^loongarch64.*$ || $TARGET =~ ^aarch64.*$ ]]; then
CORE_FEATURES="--features=mimalloc"
else
CORE_FEATURES="--features=jemalloc"

View File

@@ -11,7 +11,7 @@ on:
image_tag:
description: 'Tag for this image build'
type: string
default: 'v2.4.3'
default: 'v2.4.5'
required: true
mark_latest:
description: 'Mark this image as latest'

View File

@@ -115,7 +115,7 @@ jobs:
sudo apt install aptitude
sudo aptitude install -y libgstreamer1.0-0:arm64 gstreamer1.0-plugins-base:arm64 gstreamer1.0-plugins-good:arm64 \
libgstreamer-gl1.0-0:arm64 libgstreamer-plugins-base1.0-0:arm64 libgstreamer-plugins-good1.0-0:arm64 libwebkit2gtk-4.1-0:arm64 \
libwebkit2gtk-4.1-dev:arm64 libssl-dev:arm64 gcc-aarch64-linux-gnu
libwebkit2gtk-4.1-dev:arm64 libssl-dev:arm64 gcc-aarch64-linux-gnu libsoup-3.0-dev:arm64 libjavascriptcoregtk-4.1-dev:arm64
echo "PKG_CONFIG_SYSROOT_DIR=/usr/aarch64-linux-gnu/" >> "$GITHUB_ENV"
echo "PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig/" >> "$GITHUB_ENV"

View File

@@ -44,8 +44,8 @@ if [[ $OS =~ ^ubuntu.*$ && $TARGET =~ ^mips.*$ ]]; then
ar x libgcc.a _ctzsi2.o _clz.o _bswapsi2.o
ar rcs libctz.a _ctzsi2.o _clz.o _bswapsi2.o
rustup toolchain install nightly-x86_64-unknown-linux-gnu
rustup component add rust-src --toolchain nightly-x86_64-unknown-linux-gnu
rustup toolchain install nightly-2025-09-01-x86_64-unknown-linux-gnu
rustup component add rust-src --toolchain nightly-2025-09-01-x86_64-unknown-linux-gnu
# https://github.com/rust-lang/rust/issues/128808
# remove it after Cargo or rustc fix this.

View File

@@ -5,6 +5,7 @@ on:
branches: ["develop", "main", "releases/**"]
pull_request:
branches: ["develop", "main"]
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
@@ -15,6 +16,16 @@ defaults:
shell: bash
jobs:
cargo_fmt_check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: fmt check
working-directory: ./easytier-contrib/easytier-ohrs
run: |
bash ../../.github/workflows/install_rust.sh
rustup component add rustfmt
cargo fmt --all -- --check
pre_job:
# continue-on-error: true # Uncomment once integration is finished
runs-on: ubuntu-latest
@@ -27,9 +38,9 @@ jobs:
uses: fkirc/skip-duplicate-actions@v5
with:
# All of these options are optional, so you can remove them if you are happy with the defaults
concurrent_skipping: 'same_content_newer'
skip_after_successful_duplicate: 'true'
cancel_others: 'true'
concurrent_skipping: "same_content_newer"
skip_after_successful_duplicate: "true"
cancel_others: "true"
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-contrib/easytier-ohrs/**", ".github/workflows/ohos.yml", ".github/workflows/install_rust.sh"]'
build-ohos:
runs-on: ubuntu-latest
@@ -104,11 +115,15 @@ jobs:
cargo update easytier
ohrs doctor
ohrs build --release --arch aarch
ohrs artifact
mv package.har easytier-ohrs.har
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: easytier-ohos
path: ./easytier-contrib/easytier-ohrs/dist/arm64-v8a/libeasytier_ohrs.so
path: |
./easytier-contrib/easytier-ohrs/easytier-ohrs.har
./easytier-contrib/easytier-ohrs/dist/arm64-v8a/libeasytier_ohrs.so
retention-days: 5
if-no-files-found: error

View File

@@ -21,7 +21,7 @@ on:
version:
description: 'Version for this release'
type: string
default: 'v2.4.3'
default: 'v2.4.5'
required: true
make_latest:
description: 'Mark this release as latest'

380
Cargo.lock generated
View File

@@ -8,15 +8,6 @@ version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c71b1793ee61086797f5c80b6efa2b8ffa6d5dd703f118545808a7f2e27f7046"
[[package]]
name = "addr2line"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678"
dependencies = [
"gimli",
]
[[package]]
name = "adler"
version = "1.0.2"
@@ -129,6 +120,24 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
[[package]]
name = "android_log-sys"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d"
[[package]]
name = "android_logger"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c494134f746c14dc653a35a4ea5aca24ac368529da5370ecf41fe0341c35772f"
dependencies = [
"android_log-sys",
"env_logger",
"log",
"once_cell",
]
[[package]]
name = "android_system_properties"
version = "0.1.5"
@@ -294,16 +303,6 @@ dependencies = [
"zstd-safe",
]
[[package]]
name = "async-event"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1222afd3d2bce3995035054046a279ae7aa154d70d0766cea050073f3fd7ddf"
dependencies = [
"loom 0.5.6",
"pin-project-lite",
]
[[package]]
name = "async-executor"
version = "1.13.0"
@@ -436,9 +435,9 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]]
name = "async-trait"
version = "0.1.81"
version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e0c28dcc82d7c8ead5cb13beb15405b57b8546e93215673ff8ca0349a028107"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
@@ -645,6 +644,31 @@ dependencies = [
"tower-service",
]
[[package]]
name = "axum-extra"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45bf463831f5131b7d3c756525b305d40f1185b688565648a92e1392ca35713d"
dependencies = [
"axum 0.8.4",
"axum-core 0.5.2",
"bytes",
"form_urlencoded",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"serde",
"serde_html_form",
"serde_path_to_error",
"tower 0.5.2",
"tower-layer",
"tower-service",
]
[[package]]
name = "axum-login"
version = "0.16.0"
@@ -704,21 +728,6 @@ dependencies = [
"tracing",
]
[[package]]
name = "backtrace"
version = "0.3.73"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cc23269a4f8976d0a4d2e7109211a419fe30e8d88d677cd60b6bc79c5732e0a"
dependencies = [
"addr2line",
"cc",
"cfg-if",
"libc",
"miniz_oxide 0.7.4",
"object",
"rustc-demangle",
]
[[package]]
name = "base62"
version = "2.0.2"
@@ -1240,9 +1249,9 @@ dependencies = [
[[package]]
name = "cidr"
version = "0.2.3"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bdf600c45bd958cf2945c445264471cca8b6c8e67bc87b71affd6d7e5682621"
checksum = "bd1b64030216239a2e7c364b13cd96a2097ebf0dfe5025f2dedee14a23f2ab60"
dependencies = [
"serde",
]
@@ -1921,16 +1930,6 @@ dependencies = [
"syn 2.0.87",
]
[[package]]
name = "diatomic-waker"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28025fb55a9d815acf7b0877555f437254f373036eec6ed265116c7a5c0825e9"
dependencies = [
"loom 0.5.6",
"waker-fn",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -1981,7 +1980,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.0",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -2090,7 +2089,7 @@ checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125"
[[package]]
name = "easytier"
version = "2.4.3"
version = "2.4.5"
dependencies = [
"aes-gcm",
"anyhow",
@@ -2134,6 +2133,7 @@ dependencies = [
"http_req",
"humansize",
"humantime-serde",
"idna 1.0.3",
"kcp-sys",
"machine-uid",
"maplit",
@@ -2152,6 +2152,7 @@ dependencies = [
"petgraph 0.8.1",
"pin-project-lite",
"pnet",
"prefix-trie",
"prost",
"prost-build",
"prost-reflect",
@@ -2174,11 +2175,11 @@ dependencies = [
"service-manager",
"sha2",
"smoltcp",
"socket2",
"socket2 0.5.10",
"stun_codec",
"sys-locale",
"tabled",
"tachyonix",
"tempfile",
"thiserror 1.0.63",
"thunk-rs",
"tikv-jemalloc-ctl",
@@ -2195,7 +2196,6 @@ dependencies = [
"toml 0.8.19",
"tonic-build",
"tracing",
"tracing-appender",
"tracing-subscriber",
"tun-easytier",
"url",
@@ -2213,6 +2213,19 @@ dependencies = [
"zstd",
]
[[package]]
name = "easytier-android-jni"
version = "0.1.0"
dependencies = [
"android_logger",
"easytier",
"jni",
"log",
"once_cell",
"serde",
"serde_json",
]
[[package]]
name = "easytier-ffi"
version = "0.1.0"
@@ -2227,9 +2240,10 @@ dependencies = [
[[package]]
name = "easytier-gui"
version = "2.4.3"
version = "2.4.5"
dependencies = [
"anyhow",
"async-trait",
"chrono",
"dashmap",
"dunce",
@@ -2282,12 +2296,14 @@ dependencies = [
"anyhow",
"async-trait",
"axum 0.8.4",
"axum-extra",
"chrono",
"clap",
"dashmap",
"easytier",
"futures",
"jsonwebtoken",
"mimalloc",
"mockall",
"once_cell",
"parking_lot",
@@ -2314,7 +2330,7 @@ dependencies = [
[[package]]
name = "easytier-web"
version = "2.4.3"
version = "2.4.5"
dependencies = [
"anyhow",
"async-trait",
@@ -2330,6 +2346,7 @@ dependencies = [
"image 0.24.9",
"imageproc",
"maxminddb",
"mimalloc",
"once_cell",
"password-auth",
"rand 0.8.5",
@@ -2517,6 +2534,16 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe"
[[package]]
name = "env_logger"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580"
dependencies = [
"log",
"regex",
]
[[package]]
name = "equivalent"
version = "1.0.1"
@@ -2595,9 +2622,9 @@ dependencies = [
[[package]]
name = "fastrand"
version = "2.1.0"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "fdeflate"
@@ -2760,9 +2787,9 @@ dependencies = [
[[package]]
name = "futures-channel"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [
"futures-core",
"futures-sink",
@@ -2770,9 +2797,9 @@ dependencies = [
[[package]]
name = "futures-core"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
[[package]]
name = "futures-executor"
@@ -2798,9 +2825,9 @@ dependencies = [
[[package]]
name = "futures-io"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-lite"
@@ -2817,9 +2844,9 @@ dependencies = [
[[package]]
name = "futures-macro"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac"
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
@@ -2828,15 +2855,15 @@ dependencies = [
[[package]]
name = "futures-sink"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5"
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
[[package]]
name = "futures-task"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004"
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
[[package]]
name = "futures-timer"
@@ -2846,9 +2873,9 @@ checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24"
[[package]]
name = "futures-util"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
"futures-channel",
"futures-core",
@@ -2970,19 +2997,6 @@ dependencies = [
"x11",
]
[[package]]
name = "generator"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cc16584ff22b460a382b7feec54b23d2908d858152e5739a120b949293bd74e"
dependencies = [
"cc",
"libc",
"log",
"rustversion",
"windows 0.48.0",
]
[[package]]
name = "generator"
version = "0.8.4"
@@ -3084,12 +3098,6 @@ dependencies = [
"polyval",
]
[[package]]
name = "gimli"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd"
[[package]]
name = "gio"
version = "0.18.4"
@@ -3364,9 +3372,9 @@ dependencies = [
[[package]]
name = "heapless"
version = "0.8.0"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad"
checksum = "b1edcd5a338e64688fbdcb7531a846cfd3476a54784dcb918a0844682bc7ada5"
dependencies = [
"hash32",
"stable_deref_trait",
@@ -3699,7 +3707,7 @@ dependencies = [
"http-body",
"hyper",
"pin-project-lite",
"socket2",
"socket2 0.5.10",
"tokio",
"tower-service",
"tracing",
@@ -4032,7 +4040,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f"
dependencies = [
"socket2",
"socket2 0.5.10",
"widestring",
"windows-sys 0.48.0",
"winreg 0.50.0",
@@ -4224,7 +4232,7 @@ dependencies = [
[[package]]
name = "kcp-sys"
version = "0.1.0"
source = "git+https://github.com/EasyTier/kcp-sys?rev=0f0a0558391ba391c089806c23f369651f6c9eeb#0f0a0558391ba391c089806c23f369651f6c9eeb"
source = "git+https://github.com/EasyTier/kcp-sys?rev=71eff18c573a4a71bf99c7fabc6a8b9f211c84c1#71eff18c573a4a71bf99c7fabc6a8b9f211c84c1"
dependencies = [
"anyhow",
"auto_impl",
@@ -4442,19 +4450,6 @@ version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
[[package]]
name = "loom"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff50ecb28bb86013e935fb6683ab1f6d3a20016f123c76fd4c27470076ac30f5"
dependencies = [
"cfg-if",
"generator 0.7.5",
"scoped-tls",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "loom"
version = "0.7.2"
@@ -4462,7 +4457,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca"
dependencies = [
"cfg-if",
"generator 0.8.4",
"generator",
"scoped-tls",
"tracing",
"tracing-subscriber",
@@ -4705,7 +4700,7 @@ dependencies = [
"crossbeam-channel",
"crossbeam-epoch",
"crossbeam-utils",
"loom 0.7.2",
"loom",
"parking_lot",
"portable-atomic",
"rustc_version",
@@ -5408,15 +5403,6 @@ dependencies = [
"objc2-foundation 0.3.1",
]
[[package]]
name = "object"
version = "0.36.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27b64972346851a39438c60b341ebc01bba47464ae329e55cf343eb93964efd9"
dependencies = [
"memchr",
]
[[package]]
name = "once_cell"
version = "1.21.3"
@@ -6161,6 +6147,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85cf4c7c25f1dd66c76b451e9041a8cfce26e4ca754934fa7aed8d5a59a01d20"
dependencies = [
"cidr",
"ipnet",
"num-traits",
]
@@ -6414,7 +6401,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"socket2 0.5.10",
"thiserror 2.0.11",
"tokio",
"tracing",
@@ -6452,7 +6439,7 @@ checksum = "8bffec3605b73c6f1754535084a85229fa8a30f86014e6c81aeec4abb68b0285"
dependencies = [
"libc",
"once_cell",
"socket2",
"socket2 0.5.10",
"tracing",
"windows-sys 0.52.0",
]
@@ -7024,12 +7011,6 @@ dependencies = [
"serde_json",
]
[[package]]
name = "rustc-demangle"
version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
[[package]]
name = "rustc-hash"
version = "2.1.0"
@@ -7486,10 +7467,11 @@ dependencies = [
[[package]]
name = "serde"
version = "1.0.207"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5665e14a49a4ea1b91029ba7d3bca9f299e1f7cfa194388ccc20f14743e784f2"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
@@ -7505,10 +7487,19 @@ dependencies = [
]
[[package]]
name = "serde_derive"
version = "1.0.207"
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6aea2634c86b0e8ef2cfdc0c340baede54ec27b1e46febd7f80dffb2aa44a00e"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
@@ -7526,6 +7517,19 @@ dependencies = [
"syn 2.0.87",
]
[[package]]
name = "serde_html_form"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d2de91cf02bbc07cde38891769ccd5d4f073d22a40683aa4bc7a95781aaa2c4"
dependencies = [
"form_urlencoded",
"indexmap 2.7.1",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "serde_json"
version = "1.0.125"
@@ -7856,8 +7860,7 @@ dependencies = [
[[package]]
name = "smoltcp"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dad095989c1533c1c266d9b1e8d70a1329dd3723c3edac6d03bbd67e7bf6f4bb"
source = "git+https://github.com/smoltcp-rs/smoltcp.git?rev=0a926767a68bc88d5512afefa7529c5ecdade4ea#0a926767a68bc88d5512afefa7529c5ecdade4ea"
dependencies = [
"bitflags 1.3.2",
"byteorder",
@@ -7877,6 +7880,16 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "socket2"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881"
dependencies = [
"libc",
"windows-sys 0.60.2",
]
[[package]]
name = "softbuffer"
version = "0.4.5"
@@ -8288,9 +8301,9 @@ dependencies = [
[[package]]
name = "sync_wrapper"
version = "1.0.1"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
dependencies = [
"futures-core",
]
@@ -8372,20 +8385,6 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "tachyonix"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1924ef47bc3b427ea2a0b55ba97d0e9116e9103483ecd75a43f47a66443527c5"
dependencies = [
"async-event",
"crossbeam-utils",
"diatomic-waker",
"futures-core",
"loom 0.5.6",
"pin-project-lite",
]
[[package]]
name = "tagptr"
version = "0.2.0"
@@ -8803,15 +8802,15 @@ dependencies = [
[[package]]
name = "tempfile"
version = "3.12.0"
version = "3.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04cbcdd0c794ebb0d4cf35e88edd2f7d2c4c3e9a5a6dab322839b321c6a87a64"
checksum = "84fa4d11fadde498443cca10fd3ac23c951f0dc59e080e9f4b93d4df4e4eea53"
dependencies = [
"cfg-if",
"fastrand",
"getrandom 0.3.2",
"once_cell",
"rustix 0.38.34",
"windows-sys 0.59.0",
"rustix 1.0.7",
"windows-sys 0.61.2",
]
[[package]]
@@ -9004,28 +9003,27 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.45.1"
version = "1.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779"
checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408"
dependencies = [
"backtrace",
"bytes",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"socket2 0.6.1",
"tokio-macros",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.5.0"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
dependencies = [
"proc-macro2",
"quote",
@@ -9257,7 +9255,7 @@ dependencies = [
"percent-encoding",
"pin-project",
"prost",
"socket2",
"socket2 0.5.10",
"tokio",
"tokio-stream",
"tower 0.4.13",
@@ -9440,18 +9438,6 @@ dependencies = [
"tracing-core",
]
[[package]]
name = "tracing-appender"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3566e8ce28cc0a3fe42519fc80e6b4c943cc4c8cef275620eb8dac2d3d4e06cf"
dependencies = [
"crossbeam-channel",
"thiserror 1.0.63",
"time",
"tracing-subscriber",
]
[[package]]
name = "tracing-attributes"
version = "0.1.28"
@@ -9906,12 +9892,6 @@ dependencies = [
"libc",
]
[[package]]
name = "waker-fn"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7"
[[package]]
name = "walkdir"
version = "2.5.0"
@@ -10357,15 +10337,6 @@ dependencies = [
"windows-version",
]
[[package]]
name = "windows"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows"
version = "0.52.0"
@@ -10395,7 +10366,7 @@ dependencies = [
"windows-collections",
"windows-core 0.61.2",
"windows-future",
"windows-link",
"windows-link 0.1.3",
"windows-numerics",
]
@@ -10438,7 +10409,7 @@ checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
dependencies = [
"windows-implement 0.60.0",
"windows-interface 0.59.1",
"windows-link",
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-strings 0.4.2",
]
@@ -10450,7 +10421,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
dependencies = [
"windows-core 0.61.2",
"windows-link",
"windows-link 0.1.3",
"windows-threading",
]
@@ -10504,6 +10475,12 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-numerics"
version = "0.2.0"
@@ -10511,7 +10488,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
dependencies = [
"windows-core 0.61.2",
"windows-link",
"windows-link 0.1.3",
]
[[package]]
@@ -10540,7 +10517,7 @@ version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
dependencies = [
"windows-link",
"windows-link 0.1.3",
]
[[package]]
@@ -10570,7 +10547,7 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
dependencies = [
"windows-link",
"windows-link 0.1.3",
]
[[package]]
@@ -10618,6 +10595,15 @@ dependencies = [
"windows-targets 0.53.2",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-targets"
version = "0.42.2"
@@ -10686,7 +10672,7 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6"
dependencies = [
"windows-link",
"windows-link 0.1.3",
]
[[package]]

View File

@@ -7,6 +7,7 @@ members = [
"easytier-web",
"easytier-contrib/easytier-ffi",
"easytier-contrib/easytier-uptime",
"easytier-contrib/easytier-android-jni",
]
default-members = ["easytier", "easytier-web"]
exclude = [

View File

@@ -59,7 +59,7 @@ cargo install --git https://github.com/EasyTier/EasyTier.git easytier
# See https://easytier.cn/en/guide/installation.html#installation-methods
# 4. Linux Quick Install
wget -O- https://raw.githubusercontent.com/EasyTier/EasyTier/main/script/install.sh | sudo bash
wget -O- https://raw.githubusercontent.com/EasyTier/EasyTier/main/script/install.sh | sudo bash -s install
# 5. MacOS via Homebrew
brew tap brewforge/chinese
@@ -105,9 +105,9 @@ After successful execution, you can check the network status using `easytier-cli
```text
| ipv4 | hostname | cost | lat_ms | loss_rate | rx_bytes | tx_bytes | tunnel_proto | nat_type | id | version |
| ------------ | -------------- | ----- | ------ | --------- | -------- | -------- | ------------ | -------- | ---------- | --------------- |
| 10.126.126.1 | abc-1 | Local | * | * | * | * | udp | FullCone | 439804259 | 2.4.3-70e69a38~ |
| 10.126.126.2 | abc-2 | p2p | 3.452 | 0 | 17.33 kB | 20.42 kB | udp | FullCone | 390879727 | 2.4.3-70e69a38~ |
| | PublicServer_a | p2p | 27.796 | 0.000 | 50.01 kB | 67.46 kB | tcp | Unknown | 3771642457 | 2.4.3-70e69a38~ |
| 10.126.126.1 | abc-1 | Local | * | * | * | * | udp | FullCone | 439804259 | 2.4.5-70e69a38~ |
| 10.126.126.2 | abc-2 | p2p | 3.452 | 0 | 17.33 kB | 20.42 kB | udp | FullCone | 390879727 | 2.4.5-70e69a38~ |
| | PublicServer_a | p2p | 27.796 | 0.000 | 50.01 kB | 67.46 kB | tcp | Unknown | 3771642457 | 2.4.5-70e69a38~ |
```
You can test connectivity between nodes:
@@ -280,8 +280,6 @@ sudo easytier-core --network-name mysharednode --network-secret mysharednode
- [ZeroTier](https://www.zerotier.com/): A global virtual network for connecting devices.
- [TailScale](https://tailscale.com/): A VPN solution aimed at simplifying network configuration.
- [vpncloud](https://github.com/dswd/vpncloud): A P2P Mesh VPN
- [Candy](https://github.com/lanthora/candy): A reliable, low-latency, and anti-censorship virtual private network
### Contact Us

View File

@@ -59,7 +59,7 @@ cargo install --git https://github.com/EasyTier/EasyTier.git easytier
# 参见 https://easytier.cn/guide/installation.html#%E5%AE%89%E8%A3%85%E6%96%B9%E5%BC%8F
# 4. Linux 快速安装
wget -O- https://raw.githubusercontent.com/EasyTier/EasyTier/main/script/install.sh | sudo bash
wget -O- https://raw.githubusercontent.com/EasyTier/EasyTier/main/script/install.sh | sudo bash -s install
# 5. MacOS 通过 Homebrew 安装
brew tap brewforge/chinese
@@ -106,9 +106,9 @@ sudo easytier-core -d --network-name abc --network-secret abc -p tcp://public.ea
```text
| ipv4 | hostname | cost | lat_ms | loss_rate | rx_bytes | tx_bytes | tunnel_proto | nat_type | id | version |
| ------------ | -------------- | ----- | ------ | --------- | -------- | -------- | ------------ | -------- | ---------- | --------------- |
| 10.126.126.1 | abc-1 | Local | * | * | * | * | udp | FullCone | 439804259 | 2.4.3-70e69a38~ |
| 10.126.126.2 | abc-2 | p2p | 3.452 | 0 | 17.33 kB | 20.42 kB | udp | FullCone | 390879727 | 2.4.3-70e69a38~ |
| | PublicServer_a | p2p | 27.796 | 0.000 | 50.01 kB | 67.46 kB | tcp | Unknown | 3771642457 | 2.4.3-70e69a38~ |
| 10.126.126.1 | abc-1 | Local | * | * | * | * | udp | FullCone | 439804259 | 2.4.5-70e69a38~ |
| 10.126.126.2 | abc-2 | p2p | 3.452 | 0 | 17.33 kB | 20.42 kB | udp | FullCone | 390879727 | 2.4.5-70e69a38~ |
| | PublicServer_a | p2p | 27.796 | 0.000 | 50.01 kB | 67.46 kB | tcp | Unknown | 3771642457 | 2.4.5-70e69a38~ |
```
您可以测试节点之间的连通性:
@@ -281,8 +281,6 @@ sudo easytier-core --network-name mysharednode --network-secret mysharednode
- [ZeroTier](https://www.zerotier.com/):用于连接设备的全球虚拟网络。
- [TailScale](https://tailscale.com/):旨在简化网络配置的 VPN 解决方案。
- [vpncloud](https://github.com/dswd/vpncloud):一个 P2P 网状 VPN
- [Candy](https://github.com/lanthora/candy):一个可靠、低延迟、反审查的虚拟专用网络
### 联系我们

View File

@@ -0,0 +1,16 @@
[package]
name = "easytier-android-jni"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
jni = "0.21"
once_cell = "1.18.0"
log = "0.4"
android_logger = "0.13"
serde = { version = "1.0.220", features = ["derive"] }
serde_json = "1.0"
easytier = { path = "../../easytier" }

View File

@@ -0,0 +1,267 @@
# EasyTier Android JNI
这是 EasyTier 的 Android JNI 绑定库,允许 Android 应用程序调用 EasyTier 的网络功能。
## 功能特性
- 🚀 完整的 EasyTier FFI 接口封装
- 📱 原生 Android JNI 支持
- 🔧 支持多种 Android 架构 (arm64-v8a, armeabi-v7a, x86, x86_64)
- 🛡️ 类型安全的 Java 接口
- 📝 详细的错误处理和日志记录
## 支持的架构
- `arm64-v8a` (aarch64-linux-android)
- `armeabi-v7a` (armv7-linux-androideabi)
- `x86` (i686-linux-android)
- `x86_64` (x86_64-linux-android)
## 构建要求
### 系统要求
- Rust 1.70+
- Android NDK r21+
- Linux/macOS 开发环境
### 环境设置
1. **安装 Rust**
```bash
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source ~/.cargo/env
```
2. **安装 Android NDK**
- 下载 Android NDK: https://developer.android.com/ndk/downloads
- 解压到合适的目录
- 设置环境变量:
```bash
export ANDROID_NDK_ROOT=/path/to/android-ndk
```
3. **添加 Android 目标**
```bash
rustup target add aarch64-linux-android
rustup target add armv7-linux-androideabi
rustup target add i686-linux-android
rustup target add x86_64-linux-android
```
## 构建步骤
1. **克隆项目并进入目录**
```bash
cd /path/to/EasyTier/easytier-contrib/easytier-android-jni
```
2. **运行构建脚本**
```bash
./build.sh
```
3. **构建完成后,库文件将生成在 `target/android/` 目录下**
```
target/android/
├── arm64-v8a/
│ └── libeasytier_android_jni.so
├── armeabi-v7a/
│ └── libeasytier_android_jni.so
├── x86/
│ └── libeasytier_android_jni.so
└── x86_64/
└── libeasytier_android_jni.so
```
## Android 项目集成
### 1. 复制库文件
将生成的 `.so` 文件复制到您的 Android 项目中:
```
your-android-project/
└── src/main/
├── jniLibs/
│ ├── arm64-v8a/
│ │ └── libeasytier_android_jni.so
│ ├── armeabi-v7a/
│ │ └── libeasytier_android_jni.so
│ ├── x86/
│ │ └── libeasytier_android_jni.so
│ └── x86_64/
│ └── libeasytier_android_jni.so
└── java/
└── com/easytier/jni/
└── EasyTierJNI.java
```
### 2. 复制 Java 接口
将 `java/com/easytier/jni/EasyTierJNI.java` 复制到您的 Android 项目的相应包路径下。
### 3. 添加权限
在 `AndroidManifest.xml` 中添加必要的权限:
```xml
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
```
## 使用示例
### 基本使用
```java
import com.easytier.jni.EasyTierJNI;
import java.util.Map;
public class EasyTierManager {
// 初始化网络实例
public void startNetwork() {
String config = """
inst_name = "my_instance"
network = "my_network"
""";
try {
// 解析配置
int result = EasyTierJNI.parseConfig(config);
if (result != 0) {
String error = EasyTierJNI.getLastError();
throw new RuntimeException("配置解析失败: " + error);
}
// 启动网络实例
result = EasyTierJNI.runNetworkInstance(config);
if (result != 0) {
String error = EasyTierJNI.getLastError();
throw new RuntimeException("网络实例启动失败: " + error);
}
System.out.println("EasyTier 网络实例启动成功");
} catch (RuntimeException e) {
System.err.println("启动失败: " + e.getMessage());
}
}
// 获取网络信息
public void getNetworkInfo() {
try {
Map<String, String> infos = EasyTierJNI.collectNetworkInfosAsMap(10);
for (Map.Entry<String, String> entry : infos.entrySet()) {
System.out.println(entry.getKey() + ": " + entry.getValue());
}
} catch (RuntimeException e) {
System.err.println("获取网络信息失败: " + e.getMessage());
}
}
// 停止所有实例
public void stopNetwork() {
try {
int result = EasyTierJNI.stopAllInstances();
if (result == 0) {
System.out.println("所有网络实例已停止");
}
} catch (RuntimeException e) {
System.err.println("停止网络失败: " + e.getMessage());
}
}
}
```
### VPN 服务集成
如果您要在 Android VPN 服务中使用:
```java
public class EasyTierVpnService extends VpnService {
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
// 建立 VPN 连接
ParcelFileDescriptor vpnInterface = establishVpnInterface();
if (vpnInterface != null) {
int fd = vpnInterface.getFd();
// 设置 TUN 文件描述符
try {
EasyTierJNI.setTunFd("my_instance", fd);
} catch (RuntimeException e) {
Log.e("EasyTier", "设置 TUN FD 失败", e);
}
}
return START_STICKY;
}
private ParcelFileDescriptor establishVpnInterface() {
Builder builder = new Builder();
builder.setMtu(1500);
builder.addAddress("10.0.0.2", 24);
builder.addRoute("0.0.0.0", 0);
builder.setSession("EasyTier VPN");
return builder.establish();
}
}
```
## API 参考
### EasyTierJNI 类方法
| 方法 | 描述 | 参数 | 返回值 |
|------|------|------|--------|
| `parseConfig(String config)` | 解析 TOML 配置 | config: 配置字符串 | 0=成功, -1=失败 |
| `runNetworkInstance(String config)` | 启动网络实例 | config: 配置字符串 | 0=成功, -1=失败 |
| `setTunFd(String instanceName, int fd)` | 设置 TUN 文件描述符 | instanceName: 实例名, fd: 文件描述符 | 0=成功, -1=失败 |
| `retainNetworkInstance(String[] names)` | 保留指定实例 | names: 实例名数组 | 0=成功, -1=失败 |
| `collectNetworkInfos(int maxLength)` | 收集网络信息 | maxLength: 最大条目数 | 信息字符串数组 |
| `collectNetworkInfosAsMap(int maxLength)` | 收集网络信息为 Map | maxLength: 最大条目数 | Map<String, String> |
| `getLastError()` | 获取最后错误 | 无 | 错误消息字符串 |
| `stopAllInstances()` | 停止所有实例 | 无 | 0=成功, -1=失败 |
| `retainSingleInstance(String name)` | 保留单个实例 | name: 实例名 | 0=成功, -1=失败 |
## 故障排除
### 常见问题
1. **构建失败: "Android NDK not found"**
- 确保设置了 `ANDROID_NDK_ROOT` 环境变量
- 检查 NDK 路径是否正确
2. **运行时错误: "java.lang.UnsatisfiedLinkError"**
- 确保 `.so` 文件放在正确的 `jniLibs` 目录下
- 检查目标架构是否匹配
3. **配置解析失败**
- 检查 TOML 配置格式是否正确
- 使用 `getLastError()` 获取详细错误信息
### 调试技巧
- 启用 Android 日志查看 JNI 层的日志输出
- 使用 `adb logcat -s EasyTier-JNI` 查看相关日志
- 检查 `getLastError()` 返回的错误信息
## 许可证
本项目遵循与 EasyTier 主项目相同的许可证。
## 贡献
欢迎提交 Issue 和 Pull Request 来改进这个项目。
## 相关链接
- [EasyTier 主项目](https://github.com/EasyTier/EasyTier)
- [Android NDK 文档](https://developer.android.com/ndk)
- [Rust JNI 文档](https://docs.rs/jni/)

View File

@@ -0,0 +1,129 @@
#!/bin/bash
# EasyTier Android JNI 构建脚本
# 用于编译适用于 Android 平台的 JNI 库
# 使用 cargo-ndk 工具简化 Android 编译过程
set -e
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
REPO_ROOT=$(git rev-parse --show-toplevel)
echo -e "${GREEN}EasyTier Android JNI 构建脚本 (使用 cargo-ndk)${NC}"
echo "=============================================="
# 检查 Rust 是否安装
if ! command -v rustc &> /dev/null; then
echo -e "${RED}错误: 未找到 Rust 编译器,请先安装 Rust${NC}"
exit 1
fi
# 检查 cargo 是否安装
if ! command -v cargo &> /dev/null; then
echo -e "${RED}错误: 未找到 Cargo请先安装 Rust 工具链${NC}"
exit 1
fi
# 检查 cargo-ndk 是否安装
if ! cargo ndk --version &> /dev/null; then
echo -e "${YELLOW}cargo-ndk 未安装,正在安装...${NC}"
cargo install cargo-ndk
if ! cargo ndk --version &> /dev/null; then
echo -e "${RED}错误: cargo-ndk 安装失败${NC}"
exit 1
fi
fi
echo -e "${GREEN}cargo-ndk 版本: $(cargo ndk --version)${NC}"
# Android 目标架构映射 (cargo-ndk 使用的架构名称)
# ANDROID_TARGETS=("arm64-v8a" "armeabi-v7a" "x86" "x86_64")
ANDROID_TARGETS=("arm64-v8a")
# Android 架构到 Rust target 的映射
declare -A TARGET_MAP
TARGET_MAP["arm64-v8a"]="aarch64-linux-android"
TARGET_MAP["armeabi-v7a"]="armv7-linux-androideabi"
TARGET_MAP["x86"]="i686-linux-android"
TARGET_MAP["x86_64"]="x86_64-linux-android"
# 检查并安装所需的 Rust target
echo -e "${YELLOW}检查并安装 Android 目标架构...${NC}"
for android_target in "${ANDROID_TARGETS[@]}"; do
rust_target="${TARGET_MAP[$android_target]}"
if ! rustup target list --installed | grep -q "$rust_target"; then
echo -e "${YELLOW}安装目标架构: $rust_target (for $android_target)${NC}"
rustup target add "$rust_target"
else
echo -e "${GREEN}目标架构已安装: $rust_target (for $android_target)${NC}"
fi
done
# 创建输出目录
OUTPUT_DIR="./target/android"
mkdir -p "$OUTPUT_DIR"
# 构建函数
build_for_target() {
local android_target=$1
echo -e "${YELLOW}构建目标: $android_target${NC}"
# 首先构建 easytier-ffi
echo -e "${YELLOW}构建 easytier-ffi for $android_target${NC}"
(cd $REPO_ROOT/easytier-contrib/easytier-ffi && cargo ndk -t $android_target build --release)
# 构建 JNI 库
cargo ndk -t $android_target build --release
# 复制库文件到输出目录
# cargo-ndk 使用 Rust target 名称作为目录名,而不是 Android 架构名称
rust_target="${TARGET_MAP[$android_target]}"
mkdir -p "$OUTPUT_DIR/$android_target"
cp "$REPO_ROOT/target/$rust_target/release/libeasytier_android_jni.so" "$OUTPUT_DIR/$android_target/"
cp "$REPO_ROOT/target/$rust_target/release/libeasytier_ffi.so" "$OUTPUT_DIR/$android_target/"
echo -e "${GREEN}库文件已复制到: $OUTPUT_DIR/$android_target/${NC}"
}
# 检查 Android NDK (cargo-ndk 会自动处理 NDK 路径)
if [ -z "$ANDROID_NDK_ROOT" ] && [ -z "$ANDROID_NDK_HOME" ] && [ -z "$NDK_HOME" ]; then
echo -e "${YELLOW}警告: 未设置 Android NDK 环境变量${NC}"
echo "cargo-ndk 将尝试自动检测 NDK 路径"
echo "如果构建失败,请设置以下环境变量之一:"
echo " - ANDROID_NDK_ROOT"
echo " - ANDROID_NDK_HOME"
echo " - NDK_HOME"
else
if [ -n "$ANDROID_NDK_ROOT" ]; then
echo -e "${GREEN}使用 Android NDK: $ANDROID_NDK_ROOT${NC}"
elif [ -n "$ANDROID_NDK_HOME" ]; then
echo -e "${GREEN}使用 Android NDK: $ANDROID_NDK_HOME${NC}"
elif [ -n "$NDK_HOME" ]; then
echo -e "${GREEN}使用 Android NDK: $NDK_HOME${NC}"
fi
fi
# 构建所有目标
echo -e "${YELLOW}开始构建所有目标架构...${NC}"
for target in "${ANDROID_TARGETS[@]}"; do
build_for_target "$target"
done
echo -e "${GREEN}构建完成!${NC}"
echo -e "${GREEN}所有库文件已生成到: $OUTPUT_DIR${NC}"
echo ""
echo "目录结构:"
ls -la "$OUTPUT_DIR"/*/
echo ""
echo -e "${YELLOW}使用说明:${NC}"
echo "1. 将生成的 .so 文件复制到您的 Android 项目的 src/main/jniLibs/ 目录下"
echo "2. 将 java/com/easytier/jni/EasyTierJNI.java 复制到您的 Android 项目中"
echo "3. 在您的 Android 代码中调用 EasyTierJNI 类的方法"
echo ""
echo -e "${GREEN}注意: 此脚本使用 cargo-ndk 工具,无需手动设置复杂的环境变量${NC}"
echo -e "${GREEN}cargo-ndk 会自动处理交叉编译所需的工具链配置${NC}"

View File

@@ -0,0 +1,56 @@
# EasyTier Android JNI 示例配置文件
# 这是一个基本的配置示例,展示如何配置 EasyTier 网络实例
# 实例名称 (必需)
inst_name = "android_instance"
# 网络名称 (必需)
network = "my_easytier_network"
# 网络密钥 (可选,用于网络加密)
# network_secret = "your_secret_key_here"
# 监听地址 (可选)
# listeners = ["tcp://0.0.0.0:11010", "udp://0.0.0.0:11010"]
# 对等节点地址 (可选)
# peers = ["tcp://peer1.example.com:11010", "udp://peer2.example.com:11010"]
# 虚拟 IP 地址 (可选)
# ipv4 = "10.144.144.1"
# 主机名 (可选)
# hostname = "android-device"
# 启用 IPv6 (可选)
# ipv6 = "fd00::1"
# 代理网络 (可选)
# proxy_networks = ["192.168.1.0/24"]
# 退出节点 (可选)
# exit_nodes = ["peer1"]
# 启用加密 (可选)
# enable_encryption = true
# 启用 IPv4 转发 (可选)
# enable_ipv4 = true
# 启用 IPv6 转发 (可选)
# enable_ipv6 = false
# MTU 设置 (可选)
# mtu = 1420
# 日志级别 (可选: error, warn, info, debug, trace)
# log_level = "info"
# 禁用 P2P (可选)
# disable_p2p = false
# 使用多路径 (可选)
# use_multi_path = true
# 延迟优先 (可选)
# latency_first = false

View File

@@ -0,0 +1,78 @@
package com.easytier.jni
/** EasyTier JNI 接口类 提供 Android 应用调用 EasyTier 网络功能的接口 */
object EasyTierJNI {
init {
// 加载本地库
System.loadLibrary("easytier_android_jni")
}
/**
* 设置 TUN 文件描述符
* @param instanceName 实例名称
* @param fd TUN 文件描述符
* @return 0 表示成功,-1 表示失败
* @throws RuntimeException 当操作失败时抛出异常
*/
@JvmStatic external fun setTunFd(instanceName: String, fd: Int): Int
/**
* 解析配置字符串
* @param config TOML 格式的配置字符串
* @return 0 表示成功,-1 表示失败
* @throws RuntimeException 当配置解析失败时抛出异常
*/
@JvmStatic external fun parseConfig(config: String): Int
/**
* 运行网络实例
* @param config TOML 格式的配置字符串
* @return 0 表示成功,-1 表示失败
* @throws RuntimeException 当实例启动失败时抛出异常
*/
@JvmStatic external fun runNetworkInstance(config: String): Int
/**
* 保留指定的网络实例,停止其他实例
* @param instanceNames 要保留的实例名称数组,传入 null 或空数组将停止所有实例
* @return 0 表示成功,-1 表示失败
* @throws RuntimeException 当操作失败时抛出异常
*/
@JvmStatic external fun retainNetworkInstance(instanceNames: Array<String>?): Int
/**
* 收集网络信息
* @param maxLength 最大返回条目数
* @return 包含网络信息的字符串数组,每个元素格式为 "key=value"
* @throws RuntimeException 当操作失败时抛出异常
*/
@JvmStatic external fun collectNetworkInfos(maxLength: Int): String?
/**
* 获取最后的错误消息
* @return 错误消息字符串,如果没有错误则返回 null
*/
@JvmStatic external fun getLastError(): String?
/**
* 便利方法:停止所有网络实例
* @return 0 表示成功,-1 表示失败
* @throws RuntimeException 当操作失败时抛出异常
*/
@JvmStatic
fun stopAllInstances(): Int {
return retainNetworkInstance(null)
}
/**
* 便利方法:停止指定实例外的所有实例
* @param instanceName 要保留的实例名称
* @return 0 表示成功,-1 表示失败
* @throws RuntimeException 当操作失败时抛出异常
*/
@JvmStatic
fun retainSingleInstance(instanceName: String): Int {
return retainNetworkInstance(arrayOf(instanceName))
}
}

View File

@@ -0,0 +1,252 @@
package com.easytier.jni
import android.app.Activity
import android.content.Intent
import android.os.Handler
import android.os.Looper
import android.util.Log
import com.squareup.moshi.Moshi
import com.squareup.wire.WireJsonAdapterFactory
import common.Ipv4Inet
import web.NetworkInstanceRunningInfoMap
fun parseIpv4InetToString(inet: Ipv4Inet?): String? {
val addr = inet?.address?.addr ?: return null
val networkLength = inet.network_length
// 将 int32 转换为 IPv4 字符串
val ip =
String.format(
"%d.%d.%d.%d",
(addr shr 24) and 0xFF,
(addr shr 16) and 0xFF,
(addr shr 8) and 0xFF,
addr and 0xFF
)
return "$ip/$networkLength"
}
/** EasyTier 管理类 负责管理 EasyTier 实例的生命周期、监控网络状态变化、控制 VpnService */
class EasyTierManager(
private val activity: Activity,
private val instanceName: String,
private val networkConfig: String
) {
companion object {
private const val TAG = "EasyTierManager"
private const val MONITOR_INTERVAL = 3000L // 3秒监控间隔
}
private val handler = Handler(Looper.getMainLooper())
private var isRunning = false
private var currentIpv4: String? = null
private var currentProxyCidrs: List<String> = emptyList()
private var vpnServiceIntent: Intent? = null
// JSON 解析器
private val moshi = Moshi.Builder().add(WireJsonAdapterFactory()).build()
private val adapter = moshi.adapter(NetworkInstanceRunningInfoMap::class.java)
// 监控任务
private val monitorRunnable =
object : Runnable {
override fun run() {
if (isRunning) {
monitorNetworkStatus()
handler.postDelayed(this, MONITOR_INTERVAL)
}
}
}
/** 启动 EasyTier 实例和监控 */
fun start() {
if (isRunning) {
Log.w(TAG, "EasyTier 实例已经在运行中")
return
}
try {
// 启动 EasyTier 实例
val result = EasyTierJNI.runNetworkInstance(networkConfig)
if (result == 0) {
isRunning = true
Log.i(TAG, "EasyTier 实例启动成功: $instanceName")
// 开始监控网络状态
handler.post(monitorRunnable)
} else {
Log.e(TAG, "EasyTier 实例启动失败: $result")
val error = EasyTierJNI.getLastError()
Log.e(TAG, "错误信息: $error")
}
} catch (e: Exception) {
Log.e(TAG, "启动 EasyTier 实例时发生异常", e)
}
}
/** 停止 EasyTier 实例和监控 */
fun stop() {
if (!isRunning) {
Log.w(TAG, "EasyTier 实例未在运行")
return
}
isRunning = false
// 停止监控任务
handler.removeCallbacks(monitorRunnable)
try {
// 停止 VpnService
stopVpnService()
// 停止 EasyTier 实例
EasyTierJNI.stopAllInstances()
Log.i(TAG, "EasyTier 实例已停止: $instanceName")
// 重置状态
currentIpv4 = null
currentProxyCidrs = emptyList()
} catch (e: Exception) {
Log.e(TAG, "停止 EasyTier 实例时发生异常", e)
}
}
/** 监控网络状态 */
private fun monitorNetworkStatus() {
try {
val infosJson = EasyTierJNI.collectNetworkInfos(10)
if (infosJson.isNullOrEmpty()) {
Log.d(TAG, "未获取到网络信息")
return
}
val networkInfoMap = parseNetworkInfo(infosJson)
val networkInfo = networkInfoMap?.map?.get(instanceName)
if (networkInfo == null) {
Log.d(TAG, "未找到实例 $instanceName 的网络信息")
return
}
Log.d(TAG, "网络信息: $networkInfo")
// 检查实例是否正在运行
if (!networkInfo.running) {
Log.w(TAG, "EasyTier 实例未运行: ${networkInfo.error_msg}")
return
}
val newIpv4Inet = networkInfo.my_node_info?.virtual_ipv4
if (newIpv4Inet == null) {
Log.w(TAG, "EasyTier No Ipv4: $networkInfo")
return
}
// 获取当前节点的 IPv4 地址
val newIpv4 = parseIpv4InetToString(newIpv4Inet)
// 获取所有节点的 proxy_cidrs
val newProxyCidrs = mutableListOf<String>()
networkInfo.routes?.forEach { route ->
route.proxy_cidrs?.let { cidrs -> newProxyCidrs.addAll(cidrs) }
}
// 检查是否有变化
val ipv4Changed = newIpv4 != currentIpv4
val proxyCidrsChanged = newProxyCidrs != currentProxyCidrs
if (ipv4Changed || proxyCidrsChanged) {
Log.i(TAG, "网络状态发生变化:")
Log.i(TAG, " IPv4: $currentIpv4 -> $newIpv4")
Log.i(TAG, " Proxy CIDRs: $currentProxyCidrs -> $newProxyCidrs")
// 更新状态
currentIpv4 = newIpv4
currentProxyCidrs = newProxyCidrs.toList()
// 重启 VpnService
if (newIpv4 != null) {
restartVpnService(newIpv4, newProxyCidrs)
}
} else {
Log.d(TAG, "网络状态无变化 - IPv4: $currentIpv4, Proxy CIDRs: ${currentProxyCidrs.size}")
}
} catch (e: Exception) {
Log.e(TAG, "监控网络状态时发生异常", e)
}
}
/** 解析网络信息 JSON */
private fun parseNetworkInfo(jsonString: String): NetworkInstanceRunningInfoMap? {
return try {
adapter.fromJson(jsonString)
} catch (e: Exception) {
Log.e(TAG, "解析网络信息失败", e)
null
}
}
/** 重启 VpnService */
private fun restartVpnService(ipv4: String, proxyCidrs: List<String>) {
try {
// 先停止现有的 VpnService
stopVpnService()
// 启动新的 VpnService
startVpnService(ipv4, proxyCidrs)
} catch (e: Exception) {
Log.e(TAG, "重启 VpnService 时发生异常", e)
}
}
/** 启动 VpnService */
private fun startVpnService(ipv4: String, proxyCidrs: List<String>) {
try {
val intent = Intent(activity, EasyTierVpnService::class.java)
intent.putExtra("ipv4_address", ipv4)
intent.putStringArrayListExtra("proxy_cidrs", ArrayList(proxyCidrs))
intent.putExtra("instance_name", instanceName)
activity.startService(intent)
vpnServiceIntent = intent
Log.i(TAG, "VpnService 已启动 - IPv4: $ipv4, Proxy CIDRs: $proxyCidrs")
} catch (e: Exception) {
Log.e(TAG, "启动 VpnService 时发生异常", e)
}
}
/** 停止 VpnService */
private fun stopVpnService() {
try {
vpnServiceIntent?.let { intent ->
activity.stopService(intent)
Log.i(TAG, "VpnService 已停止")
}
vpnServiceIntent = null
} catch (e: Exception) {
Log.e(TAG, "停止 VpnService 时发生异常", e)
}
}
/** 获取当前状态信息 */
fun getStatus(): EasyTierStatus {
return EasyTierStatus(
isRunning = isRunning,
instanceName = instanceName,
currentIpv4 = currentIpv4,
currentProxyCidrs = currentProxyCidrs.toList()
)
}
/** 状态数据类 */
data class EasyTierStatus(
val isRunning: Boolean,
val instanceName: String,
val currentIpv4: String?,
val currentProxyCidrs: List<String>
)
}

View File

@@ -0,0 +1,143 @@
package com.easytier.jni
import android.content.Intent
import android.net.VpnService
import android.os.ParcelFileDescriptor
import android.util.Log
import kotlin.concurrent.thread
class EasyTierVpnService : VpnService() {
private var vpnInterface: ParcelFileDescriptor? = null
private var isRunning = false
private var instanceName: String? = null
companion object {
private const val TAG = "EasyTierVpnService"
}
override fun onCreate() {
super.onCreate()
Log.d(TAG, "VPN Service created")
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// 获取传入的参数
val ipv4Address = intent?.getStringExtra("ipv4_address")
val proxyCidrs = intent?.getStringArrayListExtra("proxy_cidrs") ?: arrayListOf()
instanceName = intent?.getStringExtra("instance_name")
if (ipv4Address == null || instanceName == null) {
Log.e(TAG, "缺少必要参数: ipv4Address=$ipv4Address, instanceName=$instanceName")
stopSelf()
return START_NOT_STICKY
}
Log.i(
TAG,
"启动 VPN Service - IPv4: $ipv4Address, Proxy CIDRs: $proxyCidrs, Instance: $instanceName"
)
thread {
try {
setupVpnInterface(ipv4Address, proxyCidrs)
} catch (t: Throwable) {
Log.e(TAG, "VPN 设置失败", t)
stopSelf()
}
}
return START_STICKY
}
private fun setupVpnInterface(ipv4Address: String, proxyCidrs: List<String>) {
try {
// 解析 IPv4 地址和网络长度
val (ip, networkLength) = parseIpv4Address(ipv4Address)
// 1. 准备 VpnService.Builder
val builder = Builder()
builder.setSession("EasyTier VPN")
.addAddress(ip, networkLength)
.addDnsServer("223.5.5.5")
.addDnsServer("114.114.114.114")
.addDisallowedApplication("com.easytier.easytiervpn")
// 2. 添加路由表 - 为每个 proxy CIDR 添加路由
proxyCidrs.forEach { cidr ->
try {
val (routeIp, routeLength) = parseCidr(cidr)
builder.addRoute(routeIp, routeLength)
Log.d(TAG, "添加路由: $routeIp/$routeLength")
} catch (e: Exception) {
Log.w(TAG, "解析 CIDR 失败: $cidr", e)
}
}
// 3. 构建虚拟网络接口
vpnInterface = builder.establish()
if (vpnInterface == null) {
Log.e(TAG, "创建 VPN 接口失败")
return
}
Log.i(TAG, "VPN 接口创建成功")
// 4. 将 TUN 文件描述符传递给 EasyTier
instanceName?.let { name ->
val fd = vpnInterface!!.fd
val result = EasyTierJNI.setTunFd(name, fd)
if (result == 0) {
Log.i(TAG, "TUN 文件描述符设置成功: $fd")
} else {
Log.e(TAG, "TUN 文件描述符设置失败: $result")
}
}
isRunning = true
// 5. 保持服务运行
while (isRunning && vpnInterface != null) {
Thread.sleep(1000)
}
} catch (t: Throwable) {
Log.e(TAG, "VPN 接口设置过程中发生错误", t)
} finally {
cleanup()
}
}
/** 解析 IPv4 地址,返回 IP 和网络长度 */
private fun parseIpv4Address(ipv4Address: String): Pair<String, Int> {
return if (ipv4Address.contains("/")) {
val parts = ipv4Address.split("/")
Pair(parts[0], parts[1].toInt())
} else {
// 默认使用 /24 网络
Pair(ipv4Address, 24)
}
}
/** 解析 CIDR返回 IP 和网络长度 */
private fun parseCidr(cidr: String): Pair<String, Int> {
val parts = cidr.split("/")
if (parts.size != 2) {
throw IllegalArgumentException("无效的 CIDR 格式: $cidr")
}
return Pair(parts[0], parts[1].toInt())
}
private fun cleanup() {
isRunning = false
vpnInterface?.close()
vpnInterface = null
Log.i(TAG, "VPN 接口已清理")
}
override fun onDestroy() {
super.onDestroy()
Log.d(TAG, "VPN Service destroyed")
cleanup()
}
}

View File

@@ -0,0 +1,41 @@
# 使用说明
1. 需要将 proto 文件放入 app/src/main/proto
2. android/gradle/libs.versions.toml 中加入依赖
```
# Wire 核心运行时
android-wire-runtime = { group = "com.squareup.wire", name = "wire-runtime", version = "5.3.11" }
moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" }
android-wire-moshi-adapter = { group = "com.squareup.wire", name = "wire-moshi-adapter", version = "5.3.11" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version = "1.9.0" }
```
3. build.gradle.kts 中加入
```
plugins {
...
alias(libs.plugins.wire)
}
dependencies {
...
implementation(libs.android.wire.runtime)
implementation(libs.android.wire.moshi.adapter)
implementation(libs.moshi)
}
...
wire {
kotlin {
rpcRole = "none"
}
}
```
4. 调用 easytier-contrib/easytier-android-jni/build.sh 生成 jni 和 ffi 的 so 文件。
并将生成的 so 文件放到 android/app/src/main/jniLibs/arm64-v8a 目录下。
5. 使用 EasyTierManager 可以拉起 EasyTier 实例并启动 Android VpnService 组件。

View File

@@ -0,0 +1,319 @@
use easytier::proto::api::manage::{NetworkInstanceRunningInfo, NetworkInstanceRunningInfoMap};
use jni::objects::{JClass, JObjectArray, JString};
use jni::sys::{jint, jstring};
use jni::JNIEnv;
use once_cell::sync::Lazy;
use std::ffi::{CStr, CString};
use std::ptr;
// 定义 KeyValuePair 结构体
#[repr(C)]
#[derive(Clone, Copy)]
pub struct KeyValuePair {
pub key: *const std::ffi::c_char,
pub value: *const std::ffi::c_char,
}
// 声明外部 C 函数
extern "C" {
fn set_tun_fd(inst_name: *const std::ffi::c_char, fd: std::ffi::c_int) -> std::ffi::c_int;
fn get_error_msg(out: *mut *const std::ffi::c_char);
fn free_string(s: *const std::ffi::c_char);
fn parse_config(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int;
fn run_network_instance(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int;
fn retain_network_instance(
inst_names: *const *const std::ffi::c_char,
length: usize,
) -> std::ffi::c_int;
fn collect_network_infos(infos: *mut KeyValuePair, max_length: usize) -> std::ffi::c_int;
}
// 初始化 Android 日志
static LOGGER_INIT: Lazy<()> = Lazy::new(|| {
android_logger::init_once(
android_logger::Config::default()
.with_max_level(log::LevelFilter::Debug)
.with_tag("EasyTier-JNI"),
);
});
// 辅助函数:从 Java String 转换为 CString
fn jstring_to_cstring(env: &mut JNIEnv, jstr: &JString) -> Result<CString, String> {
let java_str = env
.get_string(jstr)
.map_err(|e| format!("Failed to get string: {:?}", e))?;
let rust_str = java_str.to_str().map_err(|_| "Invalid UTF-8".to_string())?;
CString::new(rust_str).map_err(|_| "String contains null byte".to_string())
}
// 辅助函数:获取错误消息
fn get_last_error() -> Option<String> {
unsafe {
let mut error_ptr: *const std::ffi::c_char = ptr::null();
get_error_msg(&mut error_ptr);
if error_ptr.is_null() {
None
} else {
let error_cstr = CStr::from_ptr(error_ptr);
let error_str = error_cstr.to_string_lossy().into_owned();
free_string(error_ptr);
Some(error_str)
}
}
}
// 辅助函数:抛出 Java 异常
fn throw_exception(env: &mut JNIEnv, message: &str) {
let _ = env.throw_new("java/lang/RuntimeException", message);
}
/// 设置 TUN 文件描述符
#[no_mangle]
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_setTunFd(
mut env: JNIEnv,
_class: JClass,
inst_name: JString,
fd: jint,
) -> jint {
Lazy::force(&LOGGER_INIT);
let inst_name_cstr = match jstring_to_cstring(&mut env, &inst_name) {
Ok(cstr) => cstr,
Err(e) => {
throw_exception(&mut env, &format!("Invalid instance name: {}", e));
return -1;
}
};
unsafe {
let result = set_tun_fd(inst_name_cstr.as_ptr(), fd);
if result != 0 {
if let Some(error) = get_last_error() {
throw_exception(&mut env, &error);
}
}
result
}
}
/// 解析配置
#[no_mangle]
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_parseConfig(
mut env: JNIEnv,
_class: JClass,
config: JString,
) -> jint {
Lazy::force(&LOGGER_INIT);
let config_cstr = match jstring_to_cstring(&mut env, &config) {
Ok(cstr) => cstr,
Err(e) => {
throw_exception(&mut env, &format!("Invalid config string: {}", e));
return -1;
}
};
unsafe {
let result = parse_config(config_cstr.as_ptr());
if result != 0 {
if let Some(error) = get_last_error() {
throw_exception(&mut env, &error);
}
}
result
}
}
/// 运行网络实例
#[no_mangle]
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_runNetworkInstance(
mut env: JNIEnv,
_class: JClass,
config: JString,
) -> jint {
Lazy::force(&LOGGER_INIT);
let config_cstr = match jstring_to_cstring(&mut env, &config) {
Ok(cstr) => cstr,
Err(e) => {
throw_exception(&mut env, &format!("Invalid config string: {}", e));
return -1;
}
};
unsafe {
let result = run_network_instance(config_cstr.as_ptr());
if result != 0 {
if let Some(error) = get_last_error() {
throw_exception(&mut env, &error);
}
}
result
}
}
/// 保持网络实例
#[no_mangle]
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_retainNetworkInstance(
mut env: JNIEnv,
_class: JClass,
instance_names: JObjectArray,
) -> jint {
Lazy::force(&LOGGER_INIT);
// 处理 null 数组的情况
if instance_names.is_null() {
unsafe {
let result = retain_network_instance(ptr::null(), 0);
if result != 0 {
if let Some(error) = get_last_error() {
throw_exception(&mut env, &error);
}
}
return result;
}
}
// 获取数组长度
let array_length = match env.get_array_length(&instance_names) {
Ok(len) => len as usize,
Err(e) => {
throw_exception(&mut env, &format!("Failed to get array length: {:?}", e));
return -1;
}
};
// 如果数组为空,停止所有实例
if array_length == 0 {
unsafe {
let result = retain_network_instance(ptr::null(), 0);
if result != 0 {
if let Some(error) = get_last_error() {
throw_exception(&mut env, &error);
}
}
return result;
}
}
// 转换 Java 字符串数组为 C 字符串数组
let mut c_strings = Vec::with_capacity(array_length);
let mut c_string_ptrs = Vec::with_capacity(array_length);
for i in 0..array_length {
let java_string = match env.get_object_array_element(&instance_names, i as i32) {
Ok(obj) => obj,
Err(e) => {
throw_exception(
&mut env,
&format!("Failed to get array element {}: {:?}", i, e),
);
return -1;
}
};
if java_string.is_null() {
continue; // 跳过 null 元素
}
let jstring = JString::from(java_string);
let c_string = match jstring_to_cstring(&mut env, &jstring) {
Ok(cstr) => cstr,
Err(e) => {
throw_exception(
&mut env,
&format!("Invalid instance name at index {}: {}", i, e),
);
return -1;
}
};
c_string_ptrs.push(c_string.as_ptr());
c_strings.push(c_string); // 保持 CString 的所有权
}
unsafe {
let result = retain_network_instance(c_string_ptrs.as_ptr(), c_string_ptrs.len());
if result != 0 {
if let Some(error) = get_last_error() {
throw_exception(&mut env, &error);
}
}
result
}
}
/// 收集网络信息
#[no_mangle]
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_collectNetworkInfos(
mut env: JNIEnv,
_class: JClass,
) -> jstring {
Lazy::force(&LOGGER_INIT);
const MAX_INFOS: usize = 100;
let mut infos = vec![
KeyValuePair {
key: ptr::null(),
value: ptr::null(),
};
MAX_INFOS
];
unsafe {
let count = collect_network_infos(infos.as_mut_ptr(), MAX_INFOS);
if count < 0 {
if let Some(error) = get_last_error() {
throw_exception(&mut env, &error);
}
return ptr::null_mut();
}
let mut ret = NetworkInstanceRunningInfoMap::default();
// 使用 serde_json 构建 JSON
for info in infos.iter().take(count as usize) {
let key_ptr = info.key;
let val_ptr = info.value;
if key_ptr.is_null() || val_ptr.is_null() {
break;
}
let key = CStr::from_ptr(key_ptr).to_string_lossy();
let val = CStr::from_ptr(val_ptr).to_string_lossy();
let value = match serde_json::from_str::<NetworkInstanceRunningInfo>(val.as_ref()) {
Ok(v) => v,
Err(_) => {
throw_exception(&mut env, "Failed to parse JSON");
continue;
}
};
ret.map.insert(key.to_string(), value);
}
let json_str = serde_json::to_string(&ret).unwrap_or_else(|_| "{}".to_string());
match env.new_string(&json_str) {
Ok(jstr) => jstr.into_raw(),
Err(_) => {
throw_exception(&mut env, "Failed to create JSON string");
ptr::null_mut()
}
}
}
}
/// 获取最后的错误信息
#[no_mangle]
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_getLastError(
env: JNIEnv,
_class: JClass,
) -> jstring {
match get_last_error() {
Some(error) => match env.new_string(&error) {
Ok(jstr) => jstr.into_raw(),
Err(_) => ptr::null_mut(),
},
None => ptr::null_mut(),
}
}

View File

@@ -2,9 +2,8 @@ use std::sync::Mutex;
use dashmap::DashMap;
use easytier::{
common::config::{ConfigLoader as _, TomlConfigLoader},
common::config::{ConfigFileControl, ConfigLoader as _, TomlConfigLoader},
instance_manager::NetworkInstanceManager,
launcher::ConfigSource,
};
static INSTANCE_NAME_ID_MAP: once_cell::sync::Lazy<DashMap<String, uuid::Uuid>> =
@@ -129,13 +128,14 @@ pub unsafe extern "C" fn run_network_instance(cfg_str: *const std::ffi::c_char)
return -1;
}
let instance_id = match INSTANCE_MANAGER.run_network_instance(cfg, ConfigSource::FFI) {
Ok(id) => id,
Err(e) => {
set_error_msg(&format!("failed to start instance: {}", e));
return -1;
}
};
let instance_id =
match INSTANCE_MANAGER.run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG) {
Ok(id) => id,
Err(e) => {
set_error_msg(&format!("failed to start instance: {}", e));
return -1;
}
};
INSTANCE_NAME_ID_MAP.insert(inst_name, instance_id);
@@ -202,7 +202,7 @@ pub unsafe extern "C" fn collect_network_infos(
std::slice::from_raw_parts_mut(infos, max_length)
};
let collected_infos = match INSTANCE_MANAGER.collect_network_infos() {
let collected_infos = match INSTANCE_MANAGER.collect_network_infos_sync() {
Ok(infos) => infos,
Err(e) => {
set_error_msg(&format!("failed to collect network infos: {}", e));

View File

@@ -44,11 +44,11 @@ while true; do
# 如果 config 目录下存在 command_args 文件,则读取其中的内容作为启动参数
if [ -f "${MODDIR}/config/command_args" ]; then
TZ=Asia/Shanghai ${EASYTIER} $(cat ${MODDIR}/config/command_args) > ${LOG_FILE} &
TZ=Asia/Shanghai ${EASYTIER} $(cat ${MODDIR}/config/command_args) --hostname "$(getprop ro.product.brand)-$(getprop ro.product.model)" > ${LOG_FILE} &
sleep 5s # 等待easytier-core启动完成
update_module_description "主程序已开启(启动参数模式) | ${REDIR_STATUS}"
else
TZ=Asia/Shanghai ${EASYTIER} -c ${CONFIG_FILE} > ${LOG_FILE} &
TZ=Asia/Shanghai ${EASYTIER} -c ${CONFIG_FILE} --hostname "$(getprop ro.product.brand)-$(getprop ro.product.model)" > ${LOG_FILE} &
sleep 5s # 等待easytier-core启动完成
update_module_description "主程序已开启(配置文件模式) | ${REDIR_STATUS}"
fi

View File

@@ -22,7 +22,10 @@ get_tun_iface() {
ip link | awk -F': ' '/ tun[[:alnum:]]+/ {print $2; exit}'
}
get_hot_iface() {
ip link | awk -F': ' '/(^| )(swlan[[:alnum:]_]*|softap[[:alnum:]_]*|ap[[:alnum:]_]*)\:/ {print $2; exit}' | cut -d'@' -f1 | head -n1
ip link | awk -F': ' '/(^| )(swlan[[:alnum:]_]*|softap[[:alnum:]_]*|p2p-wlan[[:alnum:]_]*|ap[[:alnum:]_]*)\:/ {print $2; exit}' | cut -d'@' -f1 | head -n1
}
get_usb_iface() {
ip link | awk -F': ' '/(^| )(usb[[:alnum:]_]*|rndis[[:alnum:]_]*|eth[[:alnum:]_]*)\:/ {print $2; exit}' | cut -d'@' -f1 | head -n1
}
get_hot_cidr() {
ip -4 addr show dev "$1" | awk '/inet /{print $2; exit}'
@@ -33,10 +36,12 @@ set_nat_rules() {
ET_IFACE=$(get_et_iface)
[ -z "$ET_IFACE" ] && ET_IFACE="$(get_tun_iface)"
HOT_IFACE=$(get_hot_iface)
USB_IFACE=$(get_usb_iface)
HOT_CIDR=$(get_hot_cidr "$HOT_IFACE")
USB_CIDR=$(get_hot_cidr "$USB_IFACE")
# 如果热点关闭就删除自定义链
[ -n "$ET_IFACE" ] && [ -n "$HOT_CIDR" ] || return 1
[ -n "$ET_IFACE" ] && { [ -n "$HOT_CIDR" ] || [ -n "$USB_CIDR" ]; } || return 1
# 创建自定义链(如不存在)
iptables -t nat -N ET_NAT 2>/dev/null
@@ -49,13 +54,22 @@ set_nat_rules() {
iptables -I FORWARD 1 -j ET_FWD
# 添加规则
iptables -t nat -A ET_NAT -s "$HOT_CIDR" -o "$ET_IFACE" -j MASQUERADE
iptables -A ET_FWD -i "$HOT_IFACE" -o "$ET_IFACE" \
-m state --state NEW,ESTABLISHED,RELATED -j ACCEPT
iptables -A ET_FWD -i "$ET_IFACE" -o "$HOT_IFACE" \
-m state --state ESTABLISHED,RELATED -j ACCEPT
echo "[ET-NAT] Rules applied: $HOT_IFACE $HOT_CIDR$ET_IFACE" >> "$LOG_FILE"
if [ -n "$HOT_CIDR" ]; then
iptables -t nat -A ET_NAT -s "$HOT_CIDR" -o "$ET_IFACE" -j MASQUERADE
iptables -A ET_FWD -i "$HOT_IFACE" -o "$ET_IFACE" \
-m state --state NEW,ESTABLISHED,RELATED -j ACCEPT
iptables -A ET_FWD -i "$ET_IFACE" -o "$HOT_IFACE" \
-m state --state ESTABLISHED,RELATED -j ACCEPT
echo "[ET-NAT] Rules applied: $HOT_IFACE $HOT_CIDR$ET_IFACE" >> "$LOG_FILE"
fi
if [ -n "$USB_CIDR" ]; then
iptables -t nat -A ET_NAT -s "$USB_CIDR" -o "$ET_IFACE" -j MASQUERADE
iptables -A ET_FWD -i "$USB_IFACE" -o "$ET_IFACE" \
-m state --state NEW,ESTABLISHED,RELATED -j ACCEPT
iptables -A ET_FWD -i "$ET_IFACE" -o "$USB_IFACE" \
-m state --state ESTABLISHED,RELATED -j ACCEPT
echo "[ET-NAT] Rules applied: $USB_IFACE $USB_CIDR$ET_IFACE" >> "$LOG_FILE"
fi
}
flush_rules() {

View File

@@ -1,6 +1,6 @@
id=easytier_magisk
name=EasyTier_Magisk
version=v2.4.3
version=v2.4.5
versionCode=1
author=EasyTier
description=easytier magisk module @EasyTier(https://github.com/EasyTier/EasyTier)

View File

@@ -0,0 +1,9 @@
dist/
target/
.DS_Store
.idea/
package/libs
*.har
Cargo.lock

File diff suppressed because it is too large Load Diff

View File

@@ -8,9 +8,9 @@ crate-type=["cdylib"]
[dependencies]
ohos-hilog-binding = {version = "*", features = ["redirect"]}
easytier = { git = "https://github.com/EasyTier/EasyTier.git" }
napi-derive-ohos = "1.0.4"
napi-ohos = { version = "1.0.4", default-features = false, features = [
easytier = { path = "../../easytier" }
napi-derive-ohos = "1.1"
napi-ohos = { version = "1.1", default-features = false, features = [
"serde-json",
"latin1",
"chrono_date",
@@ -33,7 +33,7 @@ tracing = "0.1.41"
uuid = { version = "1.17.0", features = ["v4"] }
[build-dependencies]
napi-build-ohos = "1.0.4"
napi-build-ohos = "1.1"
[profile.dev]
panic = "unwind"
debug = true

View File

@@ -1,3 +1,3 @@
fn main () {
fn main() {
napi_build_ohos::setup();
}
}

View File

@@ -0,0 +1,2 @@
# 0.0.1
- init package

View File

@@ -0,0 +1,165 @@
GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This version of the GNU Lesser General Public License incorporates
the terms and conditions of version 3 of the GNU General Public
License, supplemented by the additional permissions listed below.
0. Additional Definitions.
As used herein, "this License" refers to version 3 of the GNU Lesser
General Public License, and the "GNU GPL" refers to version 3 of the GNU
General Public License.
"The Library" refers to a covered work governed by this License,
other than an Application or a Combined Work as defined below.
An "Application" is any work that makes use of an interface provided
by the Library, but which is not otherwise based on the Library.
Defining a subclass of a class defined by the Library is deemed a mode
of using an interface provided by the Library.
A "Combined Work" is a work produced by combining or linking an
Application with the Library. The particular version of the Library
with which the Combined Work was made is also called the "Linked
Version".
The "Minimal Corresponding Source" for a Combined Work means the
Corresponding Source for the Combined Work, excluding any source code
for portions of the Combined Work that, considered in isolation, are
based on the Application, and not on the Linked Version.
The "Corresponding Application Code" for a Combined Work means the
object code and/or source code for the Application, including any data
and utility programs needed for reproducing the Combined Work from the
Application, but excluding the System Libraries of the Combined Work.
1. Exception to Section 3 of the GNU GPL.
You may convey a covered work under sections 3 and 4 of this License
without being bound by section 3 of the GNU GPL.
2. Conveying Modified Versions.
If you modify a copy of the Library, and, in your modifications, a
facility refers to a function or data to be supplied by an Application
that uses the facility (other than as an argument passed when the
facility is invoked), then you may convey a copy of the modified
version:
a) under this License, provided that you make a good faith effort to
ensure that, in the event an Application does not supply the
function or data, the facility still operates, and performs
whatever part of its purpose remains meaningful, or
b) under the GNU GPL, with none of the additional permissions of
this License applicable to that copy.
3. Object Code Incorporating Material from Library Header Files.
The object code form of an Application may incorporate material from
a header file that is part of the Library. You may convey such object
code under terms of your choice, provided that, if the incorporated
material is not limited to numerical parameters, data structure
layouts and accessors, or small macros, inline functions and templates
(ten or fewer lines in length), you do both of the following:
a) Give prominent notice with each copy of the object code that the
Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the object code with a copy of the GNU GPL and this license
document.
4. Combined Works.
You may convey a Combined Work under terms of your choice that,
taken together, effectively do not restrict modification of the
portions of the Library contained in the Combined Work and reverse
engineering for debugging such modifications, if you also do each of
the following:
a) Give prominent notice with each copy of the Combined Work that
the Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the Combined Work with a copy of the GNU GPL and this license
document.
c) For a Combined Work that displays copyright notices during
execution, include the copyright notice for the Library among
these notices, as well as a reference directing the user to the
copies of the GNU GPL and this license document.
d) Do one of the following:
0) Convey the Minimal Corresponding Source under the terms of this
License, and the Corresponding Application Code in a form
suitable for, and under terms that permit, the user to
recombine or relink the Application with a modified version of
the Linked Version to produce a modified Combined Work, in the
manner specified by section 6 of the GNU GPL for conveying
Corresponding Source.
1) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (a) uses at run time
a copy of the Library already present on the user's computer
system, and (b) will operate properly with a modified version
of the Library that is interface-compatible with the Linked
Version.
e) Provide Installation Information, but only if you would otherwise
be required to provide such information under section 6 of the
GNU GPL, and only to the extent that such information is
necessary to install and execute a modified version of the
Combined Work produced by recombining or relinking the
Application with a modified version of the Linked Version. (If
you use option 4d0, the Installation Information must accompany
the Minimal Corresponding Source and Corresponding Application
Code. If you use option 4d1, you must provide the Installation
Information in the manner specified by section 6 of the GNU GPL
for conveying Corresponding Source.)
5. Combined Libraries.
You may place library facilities that are a work based on the
Library side by side in a single library together with other library
facilities that are not Applications and are not covered by this
License, and convey such a combined library under terms of your
choice, if you do both of the following:
a) Accompany the combined library with a copy of the same work based
on the Library, uncombined with any other library facilities,
conveyed under the terms of this License.
b) Give prominent notice with the combined library that part of it
is a work based on the Library, and explaining where to find the
accompanying uncombined form of the same work.
6. Revised Versions of the GNU Lesser General Public License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Lesser General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the
Library as you received it specifies that a certain numbered version
of the GNU Lesser General Public License "or any later version"
applies to it, you have the option of following the terms and
conditions either of that published version or of any later version
published by the Free Software Foundation. If the Library as you
received it does not specify a version number of the GNU Lesser
General Public License, you may choose any version of the GNU Lesser
General Public License ever published by the Free Software Foundation.
If the Library as you received it specifies that a proxy can decide
whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library.

View File

@@ -0,0 +1,21 @@
# `easytier-ohrs`
## Install
use `ohpm` to install package.
```shell
ohpm install easytier-ohrs
```
## API
```ts
// todo
```
## Usage
```ts
// todo
```

View File

@@ -0,0 +1,4 @@
import * as api from "libeasytier_ohrs.so";
export * from 'libeasytier_ohrs.so';
export default api;

View File

@@ -0,0 +1,10 @@
{
"license": "LGPL-3.0",
"author": "easytier",
"name": "easytier-ohrs",
"description": "",
"main": "index.ets",
"version": "0.0.1",
"types": "libs/index.d.ts",
"dependencies": {}
}

View File

@@ -0,0 +1,7 @@
{
"module": {
"name": "easytier-ohrs",
"type": "har",
"deviceTypes": ["default", "tablet", "2in1"]
},
}

View File

@@ -1,8 +1,8 @@
mod native_log;
use easytier::common::config::{ConfigLoader, TomlConfigLoader};
use easytier::common::config::{ConfigFileControl, ConfigLoader, TomlConfigLoader};
use easytier::common::constants::EASYTIER_VERSION;
use easytier::instance_manager::NetworkInstanceManager;
use easytier::launcher::ConfigSource;
use napi_derive_ohos::napi;
use ohos_hilog_binding::{hilog_debug, hilog_error};
use std::format;
@@ -18,23 +18,23 @@ pub struct KeyValuePair {
}
#[napi]
pub fn set_tun_fd(
inst_id: String,
fd: i32,
) -> bool {
pub fn easytier_version() -> String {
EASYTIER_VERSION.to_string()
}
#[napi]
pub fn set_tun_fd(inst_id: String, fd: i32) -> bool {
match Uuid::try_parse(&inst_id) {
Ok(uuid) => {
match INSTANCE_MANAGER.set_tun_fd(&uuid, fd) {
Ok(_) => {
hilog_debug!("[Rust] set tun fd {} to {}.", fd, inst_id);
true
}
Err(e) => {
hilog_error!("[Rust] cant set tun fd {} to {}. {}", fd, inst_id, e);
false
}
Ok(uuid) => match INSTANCE_MANAGER.set_tun_fd(&uuid, fd) {
Ok(_) => {
hilog_debug!("[Rust] set tun fd {} to {}.", fd, inst_id);
true
}
}
Err(e) => {
hilog_error!("[Rust] cant set tun fd {} to {}. {}", fd, inst_id, e);
false
}
},
Err(e) => {
hilog_error!("[Rust] cant covert {} to uuid. {}", inst_id, e);
false
@@ -45,9 +45,7 @@ pub fn set_tun_fd(
#[napi]
pub fn parse_config(cfg_str: String) -> bool {
match TomlConfigLoader::new_from_str(&cfg_str) {
Ok(_) => {
true
}
Ok(_) => true,
Err(e) => {
hilog_error!("[Rust] parse config failed {}", e);
false
@@ -64,8 +62,8 @@ pub fn run_network_instance(cfg_str: String) -> bool {
return false;
}
};
if INSTANCE_MANAGER.list_network_instance_ids().len() > 0 {
if INSTANCE_MANAGER.list_network_instance_ids().len() > 0 {
hilog_error!("[Rust] there is a running instance!");
return false;
}
@@ -78,7 +76,7 @@ pub fn run_network_instance(cfg_str: String) -> bool {
return false;
}
INSTANCE_MANAGER
.run_network_instance(cfg, ConfigSource::FFI)
.run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG)
.unwrap();
true
}
@@ -99,7 +97,7 @@ pub fn stop_network_instance(inst_names: Vec<String>) {
#[napi]
pub fn collect_network_infos() -> Vec<KeyValuePair> {
let mut result = Vec::new();
match INSTANCE_MANAGER.collect_network_infos() {
match INSTANCE_MANAGER.collect_network_infos_sync() {
Ok(map) => {
for (uuid, info) in map.iter() {
// convert value to json string
@@ -134,15 +132,10 @@ pub fn collect_running_network() -> Vec<String> {
#[napi]
pub fn is_running_network(inst_id: String) -> bool {
match Uuid::try_parse(&inst_id) {
Ok(uuid) => {
INSTANCE_MANAGER
.list_network_instance_ids()
.contains(&uuid)
}
Ok(uuid) => INSTANCE_MANAGER.list_network_instance_ids().contains(&uuid),
Err(e) => {
hilog_error!("[Rust] cant covert {} to uuid. {}", inst_id, e);
false
}
}
}

View File

@@ -1,7 +1,9 @@
use napi_derive_ohos::napi;
use ohos_hilog_binding::{
LogOptions, hilog_debug, hilog_error, hilog_info, hilog_warn, set_global_options,
};
use std::collections::HashMap;
use std::panic;
use napi_derive_ohos::napi;
use ohos_hilog_binding::{hilog_debug, hilog_error, hilog_info, hilog_warn, set_global_options, LogOptions};
use tracing::{Event, Subscriber};
use tracing_core::Level;
use tracing_subscriber::layer::{Context, Layer};
@@ -20,12 +22,9 @@ pub fn init_panic_hook() {
}
#[napi]
pub fn hilog_global_options(
domain: u32,
tag: String,
) {
pub fn hilog_global_options(domain: u32, tag: String) {
ohos_hilog_binding::forward_stdio_to_hilog();
set_global_options(LogOptions{
set_global_options(LogOptions {
domain,
tag: Box::leak(tag.clone().into_boxed_str()),
})
@@ -34,11 +33,9 @@ pub fn hilog_global_options(
#[napi]
pub fn init_tracing_subscriber() {
tracing_subscriber::registry()
.with(
CallbackLayer {
callback: Box::new(tracing_callback),
}
)
.with(CallbackLayer {
callback: Box::new(tracing_callback),
})
.init();
}
@@ -93,6 +90,7 @@ impl<'a> tracing::field::Visit for FieldCollector<'a> {
}
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
self.0.insert(field.name().to_string(), format!("{:?}", value));
self.0
.insert(field.name().to_string(), format!("{:?}", value));
}
}
}

View File

@@ -0,0 +1,17 @@
# Development Environment Configuration
SERVER_HOST=127.0.0.1
SERVER_PORT=8080
DATABASE_PATH=uptime.db
DATABASE_MAX_CONNECTIONS=5
HEALTH_CHECK_INTERVAL=60
HEALTH_CHECK_TIMEOUT=15
HEALTH_CHECK_RETRIES=2
RUST_LOG=debug
LOG_LEVEL=debug
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080
CORS_ALLOWED_METHODS=GET,POST,PUT,DELETE,OPTIONS
CORS_ALLOWED_HEADERS=content-type,authorization
NODE_ENV=development
API_BASE_URL=/api
ENABLE_COMPRESSION=true
ENABLE_CORS=true

View File

@@ -15,6 +15,7 @@ uuid = { version = "1.0", features = ["v4", "serde"] }
# Axum web framework
axum = { version = "0.8.4", features = ["macros"] }
axum-extra = { version = "0.10", features = ["query"] }
tower-http = { version = "0.6", features = ["cors", "compression-full"] }
tower = "0.5"
@@ -56,6 +57,8 @@ once_cell = "1.19"
# EasyTier core
easytier = { path = "../../easytier" }
mimalloc = { version = "*" }
# Testing
[dev-dependencies]
mockall = "0.12"

View File

@@ -1,5 +1,5 @@
<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { healthApi } from './api'
import {
@@ -70,6 +70,20 @@ const menuItems = [
}
]
// 根据当前路由计算默认激活的菜单项
const activeMenuIndex = computed(() => {
const p = route.path
if (p.startsWith('/submit')) return 'submit'
return 'dashboard'
})
// 处理菜单选择,避免返回 Promise 导致异步补丁问题
const handleMenuSelect = (key) => {
const item = menuItems.find((i) => i.name === key)
if (item && item.path) {
router.push(item.path)
}
}
onMounted(() => {
checkHealth()
// 定期检查健康状态
@@ -89,8 +103,8 @@ onMounted(() => {
<h1 class="app-title">EasyTier Uptime</h1>
</div>
<el-menu :default-active="route.name" mode="horizontal" class="nav-menu"
@select="(key) => router.push(menuItems.find(item => item.name === key)?.path || '/')">
<el-menu :default-active="activeMenuIndex" mode="horizontal" class="nav-menu"
@select="handleMenuSelect">
<el-menu-item v-for="item in menuItems" :key="item.name" :index="item.name">
<el-icon>
<component :is="item.icon" />

View File

@@ -6,6 +6,18 @@ const api = axios.create({
timeout: 10000,
headers: {
'Content-Type': 'application/json'
},
// 保证数组参数使用 repeated keys 风格序列化tags=a&tags=b
paramsSerializer: params => {
const usp = new URLSearchParams()
Object.entries(params || {}).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach(v => usp.append(key, v))
} else if (value !== undefined && value !== null && value !== '') {
usp.append(key, value)
}
})
return usp.toString()
}
})
@@ -50,9 +62,15 @@ api.interceptors.response.use(
// 节点相关API
export const nodeApi = {
// 获取节点列表
async getNodes(params = {}) {
const response = await api.get('/api/nodes', { params })
// 获取节点列表(支持传入 AbortController.signal 用于取消)
async getNodes(params = {}, options = {}) {
const response = await api.get('/api/nodes', { params, signal: options.signal })
return response.data
},
// 获取所有标签
async getAllTags() {
const response = await api.get('/api/tags')
return response.data
},
@@ -149,6 +167,28 @@ export const adminApi = {
async updateNode(id, data) {
const response = await api.put(`/api/admin/nodes/${id}`, data)
return response.data
},
// 兼容方法:获取所有节点(参数转换)
async getAllNodes(params = {}) {
const mapped = {
page: params.page,
per_page: params.page_size ?? params.per_page,
is_approved: params.approved ?? params.is_approved,
is_active: params.online ?? params.is_active,
protocol: params.protocol,
search: params.search,
tag: params.tag
}
// 移除未定义的字段
Object.keys(mapped).forEach(k => {
if (mapped[k] === undefined || mapped[k] === null || mapped[k] === '') {
delete mapped[k]
}
})
// 直接复用现有接口
const response = await api.get('/api/admin/nodes', { params: mapped })
return response.data
}
}

View File

@@ -85,6 +85,15 @@
<div class="form-tip">详细描述有助于用户选择合适的节点</div>
</el-form-item>
<!-- 新增标签管理仅在管理员编辑时显示 -->
<el-form-item v-if="props.showTags" label="标签" prop="tags">
<el-select v-model="form.tags" multiple filterable allow-create default-first-option :multiple-limit="10"
placeholder="输入后按回车添加北京、联通、IPv6、高带宽">
<el-option v-for="opt in (form.tags || [])" :key="opt" :label="opt" :value="opt" />
</el-select>
<div class="form-tip">用于分类与检索建议 1-6 个标签每个不超过 32 字符</div>
</el-form-item>
<!-- 联系方式 -->
<el-form-item label="联系方式" prop="contact_info">
<div class="contact-section">
@@ -238,6 +247,7 @@ const props = defineProps({
wechat: '',
qq_number: '',
mail: '',
tags: [],
agreed: false
})
},
@@ -264,6 +274,11 @@ const props = defineProps({
showCancel: {
type: Boolean,
default: false
},
// 新增:是否显示标签管理
showTags: {
type: Boolean,
default: false
}
})
@@ -353,6 +368,38 @@ const rules = {
},
trigger: 'change'
}
],
// 新增:标签规则(仅在显示标签管理时生效)
tags: [
{
validator: (rule, value, callback) => {
if (!props.showTags) {
callback()
return
}
if (!Array.isArray(form.tags)) {
callback(new Error('标签格式错误'))
return
}
if (form.tags.length > 10) {
callback(new Error('最多添加 10 个标签'))
return
}
for (const t of form.tags) {
const s = (t || '').trim()
if (s.length === 0) {
callback(new Error('标签不能为空'))
return
}
if (s.length > 32) {
callback(new Error('每个标签不超过 32 字符'))
return
}
}
callback()
},
trigger: 'change'
}
]
}
@@ -362,7 +409,7 @@ const canTest = computed(() => {
})
const buildDataFromForm = () => {
return {
const data = {
name: form.name || 'Test Node',
host: form.host,
port: form.port,
@@ -376,6 +423,11 @@ const buildDataFromForm = () => {
qq_number: form.qq_number || null,
mail: form.mail || null
}
// 仅在管理员编辑时附带标签
if (props.showTags) {
data.tags = Array.isArray(form.tags) ? form.tags : []
}
return data
}
// 测试连接
@@ -441,6 +493,10 @@ const resetFields = () => {
if (formRef.value) {
formRef.value.resetFields()
}
// 重置标签
if (props.showTags) {
form.tags = []
}
testResult.value = null
emit('reset')
}

View File

@@ -0,0 +1,62 @@
// Deterministic tag color generator (pure frontend)
// Same tag => same color; different tags => different colors
function stringHash(str) {
const s = String(str)
let hash = 5381
for (let i = 0; i < s.length; i++) {
hash = (hash * 33) ^ s.charCodeAt(i)
}
return hash >>> 0 // ensure positive
}
function hslToRgb(h, s, l) {
// h,s,l in [0,1]
let r, g, b
if (s === 0) {
r = g = b = l // achromatic
} else {
const hue2rgb = (p, q, t) => {
if (t < 0) t += 1
if (t > 1) t -= 1
if (t < 1 / 6) return p + (q - p) * 6 * t
if (t < 1 / 2) return q
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6
return p
}
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
const p = 2 * l - q
r = hue2rgb(p, q, h + 1 / 3)
g = hue2rgb(p, q, h)
b = hue2rgb(p, q, h - 1 / 3)
}
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]
}
function rgbToHex(r, g, b) {
const toHex = (v) => v.toString(16).padStart(2, '0')
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
}
export function getTagStyle(tag) {
const hash = stringHash(tag)
const hue = hash % 360 // 0-359
const saturation = 65 // percentage
const lightness = 47 // percentage
const rgb = hslToRgb(hue / 360, saturation / 100, lightness / 100)
const hex = rgbToHex(rgb[0], rgb[1], rgb[2])
// Perceived brightness for text color selection
const brightness = rgb[0] * 0.299 + rgb[1] * 0.587 + rgb[2] * 0.114
const textColor = brightness > 160 ? '#1f1f1f' : '#ffffff'
return {
backgroundColor: hex,
borderColor: hex,
color: textColor
}
}

View File

@@ -196,6 +196,17 @@
<el-table-column prop="description" label="描述" min-width="150" show-overflow-tooltip />
<el-table-column prop="tags" label="标签" min-width="160">
<template #default="{ row }">
<div class="tags-list">
<el-tag v-for="(tag, idx) in row.tags" :key="tag + idx" size="small" class="tag-chip" :style="getTagStyle(tag)">
{{ tag }}
</el-tag>
<span v-if="!row.tags || row.tags.length === 0" class="text-muted"></span>
</div>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="160">
<template #default="{ row }">
{{ formatDate(row.created_at) }}
@@ -228,8 +239,8 @@
<!-- 编辑节点对话框 -->
<el-dialog v-model="editDialogVisible" title="编辑节点" width="800px" destroy-on-close>
<NodeForm v-if="editDialogVisible" v-model="editForm" :submitting="updating" submit-text="更新节点" submit-icon="Edit"
:show-connection-test="false" :show-agreement="false" :show-cancel="true" @submit="handleUpdateNode"
@cancel="editDialogVisible = false" @reset="resetEditForm" />
:show-connection-test="false" :show-agreement="false" :show-cancel="true" :show-tags="true"
@submit="handleUpdateNode" @cancel="editDialogVisible = false" @reset="resetEditForm" />
</el-dialog>
</div>
</template>
@@ -240,6 +251,7 @@ import dayjs from 'dayjs'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Check, Clock, DataAnalysis, CircleCheck, Loading } from '@element-plus/icons-vue'
import NodeForm from '../components/NodeForm.vue'
import { getTagStyle } from '../utils/tagColor'
export default {
name: 'AdminDashboard',
@@ -270,7 +282,8 @@ export default {
protocol: 'tcp',
version: '',
max_connections: 100,
description: ''
description: '',
tags: []
},
editingNodeId: null,
updating: false
@@ -302,6 +315,7 @@ export default {
}
},
methods: {
getTagStyle,
async loadNodes() {
try {
this.loading = true
@@ -379,13 +393,47 @@ export default {
},
editNode(node) {
this.editingNodeId = node.id
this.editForm = node
// 只取需要的字段,并复制 tags 数组以避免引用问题
this.editForm = {
id: node.id,
name: node.name,
host: node.host,
port: node.port,
protocol: node.protocol,
version: node.version,
max_connections: node.max_connections,
description: node.description || '',
allow_relay: node.allow_relay,
network_name: node.network_name,
network_secret: node.network_secret,
wechat: node.wechat,
qq_number: node.qq_number,
mail: node.mail,
tags: Array.isArray(node.tags) ? [...node.tags] : []
}
this.editDialogVisible = true
},
async handleUpdateNode(formData) {
try {
this.updating = true
await adminApi.updateNode(this.editingNodeId, formData)
// 确保提交包含 tags 字段(为空数组也传)
const payload = {
name: formData.name,
host: formData.host,
port: formData.port,
protocol: formData.protocol,
version: formData.version,
max_connections: formData.max_connections,
description: formData.description,
allow_relay: formData.allow_relay,
network_name: formData.network_name,
network_secret: formData.network_secret,
wechat: formData.wechat,
qq_number: formData.qq_number,
mail: formData.mail,
tags: Array.isArray(formData.tags) ? formData.tags : []
}
await adminApi.updateNode(this.editingNodeId, payload)
ElMessage.success('节点更新成功')
this.editDialogVisible = false
await this.loadNodes()
@@ -576,4 +624,8 @@ export default {
.text-secondary {
color: #909399;
}
.tag-chip {
margin-right: 4px;
}
</style>

View File

@@ -56,7 +56,7 @@
<!-- 搜索和筛选 -->
<el-card class="filter-card">
<el-row :gutter="20">
<el-row :gutter="26">
<el-col :span="8">
<el-input v-model="searchText" placeholder="搜索节点名称、主机地址或描述" prefix-icon="Search" clearable
@input="handleSearch" />
@@ -77,14 +77,16 @@
<el-option label="WSS" value="wss" />
</el-select>
</el-col>
<!-- 新增标签多选筛选 -->
<el-col :span="4">
<el-button type="primary" @click="refreshData" :loading="loading">
<el-icon>
<Refresh />
</el-icon>
刷新
</el-button>
<el-select v-model="selectedTags" multiple collapse-tags collapse-tags-tooltip filterable clearable
placeholder="按标签筛选(可多选)" @change="handleFilter">
<el-option v-for="tag in allTags" :key="tag" :label="tag" :value="tag">
<span class="tag-option" :style="getTagStyle(tag)">{{ tag }}</span>
</el-option>
</el-select>
</el-col>
<el-col :span="4">
<el-button type="success" @click="$router.push('/submit')">
<el-icon>
@@ -97,17 +99,24 @@
</el-card>
<!-- 节点列表 -->
<el-card class="nodes-card">
<el-card ref="nodesCardRef" class="nodes-card">
<template #header>
<div class="card-header">
<span>节点列表</span>
<span>
节点列表
<el-button type="text" :loading="loading" @click="refreshData" style="margin-left: 8px;">
<el-icon>
<Refresh />
</el-icon>
</el-button>
</span>
<el-tag :type="loading ? 'info' : 'success'">
{{ loading ? '加载中...' : `${pagination.total} 个节点` }}
</el-tag>
</div>
</template>
<el-table :data="nodes" v-loading="loading" stripe style="width: 100%" row-key="id">
<el-table ref="tableRef" :data="nodes" v-loading="loading" stripe style="width: 100%" row-key="id">
<!-- 展开列 -->
<el-table-column type="expand" width="50">
<template #default="{ row }">
@@ -176,6 +185,18 @@
<span class="description">{{ row.description || '暂无描述' }}</span>
</template>
</el-table-column>
<!-- 新增标签展示 -->
<el-table-column label="标签" min-width="160">
<template #default="{ row }">
<div class="tags-list">
<el-tag v-for="(tag, idx) in row.tags" :key="tag + idx" size="small" class="tag-chip"
:style="getTagStyle(tag)" style="margin: 2px 6px 2px 0;">
{{ tag }}
</el-tag>
<span v-if="!row.tags || row.tags.length === 0" class="text-muted"></span>
</div>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180">
<template #default="{ row }">
@@ -223,6 +244,16 @@
<el-descriptions-item label="创建时间">{{ formatDate(selectedNode.created_at) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatDate(selectedNode.updated_at) }}</el-descriptions-item>
<el-descriptions-item label="描述" :span="2">{{ selectedNode.description || '暂无描述' }}</el-descriptions-item>
<!-- 新增标签 -->
<el-descriptions-item label="标签" :span="2">
<div class="tags-list">
<el-tag v-for="(tag, idx) in selectedNode.tags" :key="tag + idx" size="small" class="tag-chip"
style="margin: 2px 6px 2px 0;">
{{ tag }}
</el-tag>
<span v-if="!selectedNode.tags || selectedNode.tags.length === 0" class="text-muted"></span>
</div>
</el-descriptions-item>
</el-descriptions>
<!-- 健康状态统计 -->
@@ -261,7 +292,7 @@
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { ref, reactive, onMounted, computed, watch, nextTick, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus'
import { nodeApi } from '../api'
import dayjs from 'dayjs'
@@ -276,23 +307,31 @@ import {
Refresh,
Plus
} from '@element-plus/icons-vue'
import { getTagStyle } from '../utils/tagColor'
// 响应式数据
const loading = ref(false)
const nodes = ref([])
const searchText = ref('')
const statusFilter = ref('true')
const statusFilter = ref('')
const protocolFilter = ref('')
const selectedTags = ref([])
const allTags = ref([])
const detailDialogVisible = ref(false)
const selectedNode = ref(null)
const healthStats = ref(null)
const expandedRows = ref([])
const apiUrl = ref(window.location.href)
const tableRef = ref(null)
const nodesCardRef = ref(null)
// 请求取消控制(避免重复请求覆盖)
let fetchController = null
// 分页数据
const pagination = reactive({
page: 1,
per_page: 20,
per_page: 50,
total: 0
})
@@ -309,6 +348,17 @@ const averageUptime = computed(() => {
})
// 方法
const fetchTags = async () => {
try {
const resp = await nodeApi.getAllTags()
if (resp.success && Array.isArray(resp.data)) {
allTags.value = resp.data
}
} catch (error) {
console.error('获取标签列表失败:', error)
}
}
const fetchNodes = async (with_loading = true) => {
try {
if (with_loading) {
@@ -328,13 +378,26 @@ const fetchNodes = async (with_loading = true) => {
if (protocolFilter.value) {
params.protocol = protocolFilter.value
}
if (selectedTags.value && selectedTags.value.length > 0) {
params.tags = selectedTags.value
}
const response = await nodeApi.getNodes(params)
// 取消上一请求,创建新的请求控制器
if (fetchController) {
try { fetchController.abort() } catch (_) { }
}
fetchController = new AbortController()
const response = await nodeApi.getNodes(params, { signal: fetchController.signal })
if (response.success && response.data) {
nodes.value = response.data.items
pagination.total = response.data.total
}
} catch (error) {
if (error.name === 'CanceledError' || error.name === 'AbortError') {
// 被取消的旧请求,忽略
return
}
console.error('获取节点列表失败:', error)
ElMessage.error('获取节点列表失败')
} finally {
@@ -345,6 +408,7 @@ const fetchNodes = async (with_loading = true) => {
}
const refreshData = () => {
pagination.page = 1
fetchNodes()
}
@@ -408,12 +472,69 @@ const copyAddress = (address) => {
// 生命周期
onMounted(() => {
fetchTags()
fetchNodes()
// 设置定时刷新
setInterval(() => {
fetchNodes(false)
}, 3000) // 每30秒刷新一次
}, 30000) // 每30秒刷新一次
})
// 智能滚动处理:纵向滚动时页面整体滚动,横向滚动时表格内部滚动
let wheelHandler = null
let wheelTargets = []
const detachWheelHandlers = () => {
if (wheelTargets && wheelTargets.length) {
wheelTargets.forEach((el) => {
try { el.removeEventListener('wheel', wheelHandler, { capture: true }) } catch (_) { }
})
}
wheelTargets = []
}
const attachWheelHandler = () => {
const tableEl = tableRef.value?.$el
const body = tableEl ? tableEl.querySelector('.el-table__body-wrapper') : null
if (!body) return
detachWheelHandlers()
const wrap = body.querySelector('.el-scrollbar__wrap') || body
wheelHandler = (e) => {
const deltaX = e.deltaX
const deltaY = e.deltaY
// 如果是横向滚动Shift + 滚轮 或 触摸板横向滑动)
if (Math.abs(deltaX) > Math.abs(deltaY) || e.shiftKey) {
// 允许表格内部横向滚动,不阻止默认行为
return
}
// 如果是纵向滚动,阻止表格内部滚动,让页面整体滚动
if (deltaY) {
e.preventDefault()
e.stopPropagation()
const scroller = document.scrollingElement || document.documentElement
scroller.scrollTop += deltaY
}
}
body.addEventListener('wheel', wheelHandler, { passive: false, capture: true })
wheelTargets.push(body)
}
onMounted(() => {
nextTick(attachWheelHandler)
})
watch(nodes, () => {
nextTick(attachWheelHandler)
})
onBeforeUnmount(() => {
detachWheelHandlers()
})
</script>
@@ -570,4 +691,28 @@ onMounted(() => {
background-color: #fafafa;
border-top: 1px solid #ebeef5;
}
.tag-option {
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
}
:deep(.el-table__body-wrapper) {
overflow-x: auto !important;
overflow-y: hidden !important;
height: auto !important;
}
:deep(.el-card__body) {
overflow: visible !important;
}
:deep(.el-table__body-wrapper .el-scrollbar__wrap) {
overflow-x: auto !important;
overflow-y: hidden !important;
height: auto !important;
max-height: none !important;
}
</style>

View File

@@ -18,11 +18,11 @@ export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
target: 'http://localhost:11030',
changeOrigin: true,
},
'/health': {
target: 'http://localhost:8080',
target: 'http://localhost:11030',
changeOrigin: true,
}
}

View File

@@ -1,6 +1,6 @@
use std::ops::{Div, Mul};
use axum::extract::{Path, Query, State};
use axum::extract::{Path, State};
use axum::Json;
use sea_orm::{
ColumnTrait, Condition, EntityTrait, IntoActiveModel, ModelTrait, Order, PaginatorTrait,
@@ -16,6 +16,7 @@ use crate::api::{
use crate::db::entity::{self, health_records, shared_nodes};
use crate::db::{operations::*, Db};
use crate::health_checker_manager::HealthCheckerManager;
use axum_extra::extract::Query;
use std::sync::Arc;
#[derive(Clone)]
@@ -60,6 +61,35 @@ pub async fn get_nodes(
);
}
// 标签过滤(支持单标签与多标签 OR
let mut filtered_ids: Option<Vec<i32>> = None;
if !filters.tags.is_empty() {
let ids_any =
NodeOperations::filter_node_ids_by_tags_any(&app_state.db, &filters.tags).await?;
filtered_ids = match filtered_ids {
Some(mut existing) => {
// 合并去重
existing.extend(ids_any);
existing.sort();
existing.dedup();
Some(existing)
}
None => Some(ids_any),
};
}
if let Some(ids) = filtered_ids {
if ids.is_empty() {
return Ok(Json(ApiResponse::success(PaginatedResponse {
items: vec![],
total: 0,
page,
per_page,
total_pages: 0,
})));
}
query = query.filter(entity::shared_nodes::Column::Id.is_in(ids));
}
let total = query.clone().count(app_state.db.orm_db()).await?;
let nodes = query
.order_by_asc(entity::shared_nodes::Column::Id)
@@ -71,6 +101,13 @@ pub async fn get_nodes(
let mut node_responses: Vec<NodeResponse> = nodes.into_iter().map(NodeResponse::from).collect();
let total_pages = total.div_ceil(per_page as u64);
// 补充标签
let ids: Vec<i32> = node_responses.iter().map(|n| n.id).collect();
let tags_map = NodeOperations::get_nodes_tags_map(&app_state.db, &ids).await?;
for n in &mut node_responses {
n.tags = tags_map.get(&n.id).cloned().unwrap_or_default();
}
// 为每个节点添加健康状态信息
for node_response in &mut node_responses {
if let Some(mut health_record) = app_state
@@ -99,7 +136,6 @@ pub async fn get_nodes(
// remove sensitive information
node_responses.iter_mut().for_each(|node| {
tracing::info!("node: {:?}", node);
node.network_name = None;
node.network_secret = None;
@@ -161,7 +197,10 @@ pub async fn get_node(
.await?
.ok_or_else(|| ApiError::NotFound(format!("Node with id {} not found", id)))?;
Ok(Json(ApiResponse::success(NodeResponse::from(node))))
let mut resp = NodeResponse::from(node);
resp.tags = NodeOperations::get_node_tags(&app_state.db, resp.id).await?;
Ok(Json(ApiResponse::success(resp)))
}
pub async fn get_node_health(
@@ -299,7 +338,7 @@ pub async fn admin_get_nodes(
verify_admin_token(&headers)?;
let page = pagination.page.unwrap_or(1);
let per_page = pagination.per_page.unwrap_or(20);
let per_page = pagination.per_page.unwrap_or(200);
let offset = (page - 1) * per_page;
let mut query = entity::shared_nodes::Entity::find();
@@ -325,6 +364,39 @@ pub async fn admin_get_nodes(
);
}
// 标签过滤(支持单标签与多标签 OR
let mut filtered_ids: Option<Vec<i32>> = None;
if let Some(tag) = filters.tag {
let ids = NodeOperations::filter_node_ids_by_tag(&app_state.db, &tag).await?;
filtered_ids = Some(ids);
}
if let Some(tags) = filters.tags {
if !tags.is_empty() {
let ids_any = NodeOperations::filter_node_ids_by_tags_any(&app_state.db, &tags).await?;
filtered_ids = match filtered_ids {
Some(mut existing) => {
existing.extend(ids_any);
existing.sort();
existing.dedup();
Some(existing)
}
None => Some(ids_any),
};
}
}
if let Some(ids) = filtered_ids {
if ids.is_empty() {
return Ok(Json(ApiResponse::success(PaginatedResponse {
items: vec![],
total: 0,
page,
per_page,
total_pages: 0,
})));
}
query = query.filter(entity::shared_nodes::Column::Id.is_in(ids));
}
let total = query.clone().count(app_state.db.orm_db()).await?;
let nodes = query
@@ -334,7 +406,14 @@ pub async fn admin_get_nodes(
.all(app_state.db.orm_db())
.await?;
let node_responses: Vec<NodeResponse> = nodes.into_iter().map(NodeResponse::from).collect();
let mut node_responses: Vec<NodeResponse> = nodes.into_iter().map(NodeResponse::from).collect();
// 补充标签
let ids: Vec<i32> = node_responses.iter().map(|n| n.id).collect();
let tags_map = NodeOperations::get_nodes_tags_map(&app_state.db, &ids).await?;
for n in &mut node_responses {
n.tags = tags_map.get(&n.id).cloned().unwrap_or_default();
}
let total_pages = (total as f64 / per_page as f64).ceil() as u32;
@@ -366,7 +445,10 @@ pub async fn admin_approve_node(
.exec(app_state.db.orm_db())
.await?;
Ok(Json(ApiResponse::success(NodeResponse::from(updated_node))))
let mut resp = NodeResponse::from(updated_node);
resp.tags = NodeOperations::get_node_tags(&app_state.db, resp.id).await?;
Ok(Json(ApiResponse::success(resp)))
}
pub async fn admin_update_node(
@@ -432,7 +514,15 @@ pub async fn admin_update_node(
.exec(app_state.db.orm_db())
.await?;
Ok(Json(ApiResponse::success(NodeResponse::from(updated_node))))
// 更新标签
if let Some(tags) = request.tags {
NodeOperations::set_node_tags(&app_state.db, updated_node.id, tags).await?;
}
let mut resp = NodeResponse::from(updated_node);
resp.tags = NodeOperations::get_node_tags(&app_state.db, resp.id).await?;
Ok(Json(ApiResponse::success(resp)))
}
pub async fn admin_revoke_approval(
@@ -454,7 +544,10 @@ pub async fn admin_revoke_approval(
.exec(app_state.db.orm_db())
.await?;
Ok(Json(ApiResponse::success(NodeResponse::from(updated_node))))
let mut resp = NodeResponse::from(updated_node);
resp.tags = NodeOperations::get_node_tags(&app_state.db, resp.id).await?;
Ok(Json(ApiResponse::success(resp)))
}
pub async fn admin_delete_node(
@@ -505,3 +598,10 @@ fn verify_admin_token(headers: &HeaderMap) -> ApiResult<()> {
Ok(())
}
pub async fn get_all_tags(
State(app_state): State<AppState>,
) -> ApiResult<Json<ApiResponse<Vec<String>>>> {
let tags = NodeOperations::get_all_tags(&app_state.db).await?;
Ok(Json(ApiResponse::success(tags)))
}

View File

@@ -162,6 +162,9 @@ pub struct UpdateNodeRequest {
#[validate(email)]
pub mail: Option<String>,
// 标签字段(仅管理员可用)
pub tags: Option<Vec<String>>,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -198,6 +201,7 @@ pub struct NodeResponse {
pub qq_number: Option<String>,
pub wechat: Option<String>,
pub mail: Option<String>,
pub tags: Vec<String>,
}
impl From<entity::shared_nodes::Model> for NodeResponse {
@@ -247,6 +251,7 @@ impl From<entity::shared_nodes::Model> for NodeResponse {
} else {
Some(node.mail)
},
tags: Vec::new(),
}
}
}
@@ -281,6 +286,8 @@ pub struct NodeFilterParams {
pub is_active: Option<bool>,
pub protocol: Option<String>,
pub search: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -313,4 +320,6 @@ pub struct AdminNodeFilterParams {
pub is_approved: Option<bool>,
pub protocol: Option<String>,
pub search: Option<String>,
pub tag: Option<String>,
pub tags: Option<Vec<String>>,
}

View File

@@ -6,7 +6,7 @@ use tower_http::cors::CorsLayer;
use super::handlers::AppState;
use super::handlers::{
admin_approve_node, admin_delete_node, admin_get_nodes, admin_login, admin_revoke_approval,
admin_update_node, admin_verify_token, create_node, get_node, get_node_health,
admin_update_node, admin_verify_token, create_node, get_all_tags, get_node, get_node_health,
get_node_health_stats, get_nodes, health_check,
};
use crate::api::{get_node_connect_url, test_connection};
@@ -38,6 +38,7 @@ pub fn create_routes() -> Router<AppState> {
.route("/node/{id}", get(get_node_connect_url))
.route("/health", get(health_check))
.route("/api/nodes", get(get_nodes).post(create_node))
.route("/api/tags", get(get_all_tags))
.route("/api/test_connection", post(test_connection))
.route("/api/nodes/{id}/health", get(get_node_health))
.route("/api/nodes/{id}/health/stats", get(get_node_health_stats))

View File

@@ -2,6 +2,8 @@ use std::env;
use std::net::{IpAddr, SocketAddr};
use std::path::PathBuf;
use easytier::common::config::{ConsoleLoggerConfig, FileLoggerConfig, LoggingConfig};
#[derive(Debug, Clone)]
pub struct AppConfig {
pub server: ServerConfig,
@@ -32,12 +34,6 @@ pub struct HealthCheckConfig {
pub max_retries: u32,
}
#[derive(Debug, Clone)]
pub struct LoggingConfig {
pub level: String,
pub rust_log: String,
}
#[derive(Debug, Clone)]
pub struct CorsConfig {
pub allowed_origins: Vec<String>,
@@ -100,8 +96,14 @@ impl AppConfig {
};
let logging_config = LoggingConfig {
level: env::var("LOG_LEVEL").unwrap_or_else(|_| "info".to_string()),
rust_log: env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()),
file_logger: Some(FileLoggerConfig {
level: Some(env::var("LOG_LEVEL").unwrap_or_else(|_| "info".to_string())),
file: Some("easytier-uptime.log".to_string()),
..Default::default()
}),
console_logger: Some(ConsoleLoggerConfig {
level: Some(env::var("LOG_LEVEL").unwrap_or_else(|_| "info".to_string())),
}),
};
let cors_config = CorsConfig {
@@ -161,8 +163,14 @@ impl AppConfig {
max_retries: 3,
},
logging: LoggingConfig {
level: "info".to_string(),
rust_log: "info".to_string(),
file_logger: Some(FileLoggerConfig {
level: Some("info".to_string()),
file: Some("easytier-uptime.log".to_string()),
..Default::default()
}),
console_logger: Some(ConsoleLoggerConfig {
level: Some("info".to_string()),
}),
},
cors: CorsConfig {
allowed_origins: vec![

View File

@@ -3,4 +3,5 @@
pub mod prelude;
pub mod health_records;
pub mod node_tags;
pub mod shared_nodes;

View File

@@ -0,0 +1,32 @@
//! `SeaORM` Entity for node tags
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "node_tags")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub node_id: i32,
pub tag: String,
pub created_at: DateTimeWithTimeZone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::shared_nodes::Entity",
from = "Column::NodeId",
to = "super::shared_nodes::Column::Id"
)]
SharedNodes,
}
impl Related<super::shared_nodes::Entity> for Entity {
fn to() -> RelationDef {
Relation::SharedNodes.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,4 +1,5 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0
pub use super::health_records::Entity as HealthRecords;
pub use super::node_tags::Entity as NodeTags;
pub use super::shared_nodes::Entity as SharedNodes;

View File

@@ -33,6 +33,9 @@ pub struct Model {
pub enum Relation {
#[sea_orm(has_many = "super::health_records::Entity")]
HealthRecords,
// add relation to node_tags
#[sea_orm(has_many = "super::node_tags::Entity")]
NodeTags,
}
impl Related<super::health_records::Entity> for Entity {
@@ -41,4 +44,10 @@ impl Related<super::health_records::Entity> for Entity {
}
}
impl Related<super::node_tags::Entity> for Entity {
fn to() -> RelationDef {
Relation::NodeTags.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -4,6 +4,7 @@ use crate::db::Db;
use crate::db::HealthStats;
use crate::db::HealthStatus;
use sea_orm::*;
use std::collections::{HashMap, HashSet};
/// 节点管理操作
pub struct NodeOperations;
@@ -229,6 +230,128 @@ impl HealthOperations {
Ok(result.rows_affected)
}
}
impl NodeOperations {
/// 获取节点的全部标签
pub async fn get_node_tags(db: &Db, node_id: i32) -> Result<Vec<String>, DbErr> {
let tags = node_tags::Entity::find()
.filter(node_tags::Column::NodeId.eq(node_id))
.all(db.orm_db())
.await?;
Ok(tags.into_iter().map(|m| m.tag).collect())
}
/// 批量获取节点的标签映射
pub async fn get_nodes_tags_map(
db: &Db,
node_ids: &[i32],
) -> Result<HashMap<i32, Vec<String>>, DbErr> {
if node_ids.is_empty() {
return Ok(HashMap::new());
}
let tags = node_tags::Entity::find()
.filter(node_tags::Column::NodeId.is_in(node_ids.to_vec()))
.order_by_asc(node_tags::Column::NodeId)
.all(db.orm_db())
.await?;
let mut map: HashMap<i32, Vec<String>> = HashMap::new();
for t in tags {
map.entry(t.node_id).or_default().push(t.tag);
}
Ok(map)
}
/// 使用标签过滤节点返回节点ID
pub async fn filter_node_ids_by_tag(db: &Db, tag: &str) -> Result<Vec<i32>, DbErr> {
let tagged = node_tags::Entity::find()
.filter(node_tags::Column::Tag.eq(tag))
.all(db.orm_db())
.await?;
Ok(tagged.into_iter().map(|m| m.node_id).collect())
}
/// 设置节点标签(替换为给定集合)
pub async fn set_node_tags(db: &Db, node_id: i32, tags: Vec<String>) -> Result<(), DbErr> {
// 去重与清理空白
let mut set: HashSet<String> = HashSet::new();
for tag in tags.into_iter() {
let trimmed = tag.trim();
if !trimmed.is_empty() {
set.insert(trimmed.to_string());
}
}
// 取出当前标签
let existing = node_tags::Entity::find()
.filter(node_tags::Column::NodeId.eq(node_id))
.all(db.orm_db())
.await?;
let existing_set: HashSet<String> = existing.iter().map(|m| m.tag.clone()).collect();
// 需要删除的
let to_delete: Vec<i32> = existing
.iter()
.filter(|m| !set.contains(&m.tag))
.map(|m| m.id)
.collect();
// 需要新增的
let to_insert: Vec<String> = set
.into_iter()
.filter(|t| !existing_set.contains(t))
.collect();
// 执行删除
if !to_delete.is_empty() {
node_tags::Entity::delete_many()
.filter(node_tags::Column::Id.is_in(to_delete))
.exec(db.orm_db())
.await?;
}
// 执行新增
for t in to_insert {
let now = chrono::Utc::now().fixed_offset();
let am = node_tags::ActiveModel {
id: NotSet,
node_id: Set(node_id),
tag: Set(t),
created_at: Set(now),
};
node_tags::Entity::insert(am).exec(db.orm_db()).await?;
}
Ok(())
}
// 新增:获取所有唯一标签(按字母排序)
pub async fn get_all_tags(db: &Db) -> Result<Vec<String>, DbErr> {
let rows = node_tags::Entity::find().all(db.orm_db()).await?;
let mut set: HashSet<String> = HashSet::new();
for r in rows {
set.insert(r.tag);
}
let mut list: Vec<String> = set.into_iter().collect();
list.sort();
Ok(list)
}
// 新增使用多标签OR 语义过滤节点返回匹配的节点ID
pub async fn filter_node_ids_by_tags_any(db: &Db, tags: &[String]) -> Result<Vec<i32>, DbErr> {
if tags.is_empty() {
return Ok(vec![]);
}
let tagged = node_tags::Entity::find()
.filter(node_tags::Column::Tag.is_in(tags.to_vec()))
.all(db.orm_db())
.await?;
let mut set: HashSet<i32> = HashSet::new();
for m in tagged {
set.insert(m.node_id);
}
Ok(set.into_iter().collect())
}
}
#[cfg(test)]
mod tests {

View File

@@ -8,12 +8,11 @@ use anyhow::Context as _;
use dashmap::DashMap;
use easytier::{
common::{
config::{ConfigLoader, NetworkIdentity, PeerConfig, TomlConfigLoader},
config::{ConfigFileControl, ConfigLoader, NetworkIdentity, PeerConfig, TomlConfigLoader},
scoped_task::ScopedTask,
},
defer,
instance_manager::NetworkInstanceManager,
launcher::ConfigSource,
};
use serde::{Deserialize, Serialize};
use sqlx::any;
@@ -392,7 +391,7 @@ impl HealthChecker {
.delete_network_instance(vec![cfg.get_id()]);
});
self.instance_mgr
.run_network_instance(cfg.clone(), ConfigSource::FFI)
.run_network_instance(cfg.clone(), false, ConfigFileControl::STATIC_CONFIG)
.with_context(|| "failed to run network instance")?;
let now = Instant::now();
@@ -436,7 +435,7 @@ impl HealthChecker {
);
self.instance_mgr
.run_network_instance(cfg.clone(), ConfigSource::FFI)
.run_network_instance(cfg.clone(), true, ConfigFileControl::STATIC_CONFIG)
.with_context(|| "failed to run network instance")?;
self.inst_id_map.insert(node_id, cfg.get_id());
@@ -497,7 +496,7 @@ impl HealthChecker {
instance_mgr: Arc<NetworkInstanceManager>,
// return version, response time on healthy, conn_count
) -> anyhow::Result<(String, u64, u32)> {
let Some(instance) = instance_mgr.get_network_info(&inst_id) else {
let Some(instance) = instance_mgr.get_network_info(&inst_id).await else {
anyhow::bail!("healthy check node is not started");
};
@@ -650,7 +649,7 @@ impl HealthChecker {
node_id,
HealthStatus::Unhealthy,
None,
Some(e.to_string()),
Some(format!("inst id: {}, err: {}", inst_id, e)),
)
.await;
}

View File

@@ -11,6 +11,7 @@ use api::routes::create_routes;
use clap::Parser;
use config::AppConfig;
use db::{operations::NodeOperations, Db};
use easytier::utils::init_logger;
use health_checker::HealthChecker;
use health_checker_manager::HealthCheckerManager;
use std::env;
@@ -22,6 +23,11 @@ use tracing_subscriber::EnvFilter;
use crate::db::cleanup::{CleanupConfig, CleanupManager};
use mimalloc::MiMalloc;
#[global_allocator]
static GLOBAL_MIMALLOC: MiMalloc = MiMalloc;
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
@@ -30,24 +36,13 @@ struct Args {
admin_password: Option<String>,
}
#[tokio::main]
#[tokio::main(flavor = "multi_thread", worker_threads = 4)]
async fn main() -> anyhow::Result<()> {
// 加载配置
let config = AppConfig::default();
// 初始化日志
tracing_subscriber::fmt()
.with_max_level(match config.logging.level.as_str() {
"debug" => tracing::Level::DEBUG,
"info" => tracing::Level::INFO,
"warn" => tracing::Level::WARN,
"error" => tracing::Level::ERROR,
_ => tracing::Level::INFO,
})
.with_target(false)
.with_thread_ids(true)
.with_env_filter(EnvFilter::new("easytier_uptime"))
.init();
let _ = init_logger(&config.logging, false);
// 解析命令行参数
let args = Args::parse();

View File

@@ -0,0 +1,119 @@
use sea_orm_migration::{prelude::*, schema::*};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[derive(DeriveIden)]
enum NodeTags {
Table,
Id,
NodeId,
Tag,
CreatedAt,
}
#[derive(DeriveIden)]
enum SharedNodes {
Table,
Id,
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// 创建 node_tags 表
manager
.create_table(
Table::create()
.table(NodeTags::Table)
.if_not_exists()
.col(pk_auto(NodeTags::Id).not_null())
.col(integer(NodeTags::NodeId).not_null())
.col(string(NodeTags::Tag).not_null())
.col(
timestamp_with_time_zone(NodeTags::CreatedAt)
.not_null()
.default(Expr::current_timestamp()),
)
.foreign_key(
ForeignKey::create()
.name("fk_node_tags_node")
.from(NodeTags::Table, NodeTags::NodeId)
.to(SharedNodes::Table, SharedNodes::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
// 索引NodeId
manager
.create_index(
Index::create()
.name("idx_node_tags_node")
.table(NodeTags::Table)
.col(NodeTags::NodeId)
.to_owned(),
)
.await?;
// 索引Tag
manager
.create_index(
Index::create()
.name("idx_node_tags_tag")
.table(NodeTags::Table)
.col(NodeTags::Tag)
.to_owned(),
)
.await?;
// 唯一索引:每个节点的标签唯一
manager
.create_index(
Index::create()
.name("uniq_node_tag_per_node")
.table(NodeTags::Table)
.col(NodeTags::NodeId)
.col(NodeTags::Tag)
.unique()
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// 先删除索引
manager
.drop_index(
Index::drop()
.name("idx_node_tags_node")
.table(NodeTags::Table)
.to_owned(),
)
.await?;
manager
.drop_index(
Index::drop()
.name("idx_node_tags_tag")
.table(NodeTags::Table)
.to_owned(),
)
.await?;
manager
.drop_index(
Index::drop()
.name("uniq_node_tag_per_node")
.table(NodeTags::Table)
.to_owned(),
)
.await?;
manager
.drop_table(Table::drop().table(NodeTags::Table).to_owned())
.await
}
}

View File

@@ -1,12 +1,16 @@
use sea_orm_migration::prelude::*;
mod m20250101_000001_create_tables;
mod m20250101_000002_create_node_tags;
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![Box::new(m20250101_000001_create_tables::Migration)]
vec![
Box::new(m20250101_000001_create_tables::Migration),
Box::new(m20250101_000002_create_node_tags::Migration),
]
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "easytier-gui",
"type": "module",
"version": "2.4.3",
"version": "2.4.5",
"private": true,
"packageManager": "pnpm@9.12.1+sha512.e5a7e52a4183a02d5931057f7a0dbff9d5e9ce3161e33fa68ae392125b79282a8a8a470a51dfc8a0ed86221442eb2fb57019b0990ed24fab519bf0e1bc5ccfc4",
"scripts": {
@@ -13,18 +13,17 @@
"lint:fix": "eslint . --ignore-pattern src-tauri --fix"
},
"dependencies": {
"@primevue/themes": "4.3.3",
"@primeuix/themes": "^1.2.3",
"@tauri-apps/plugin-autostart": "2.0.0",
"@tauri-apps/plugin-clipboard-manager": "2.3.0",
"@tauri-apps/plugin-os": "2.3.0",
"@tauri-apps/plugin-process": "2.3.0",
"@tauri-apps/plugin-shell": "2.3.0",
"@vueuse/core": "^11.2.0",
"aura": "link:@primevue\\themes\\aura",
"easytier-frontend-lib": "workspace:*",
"ip-num": "1.5.1",
"pinia": "^2.2.4",
"primevue": "4.3.3",
"primevue": "^4.3.9",
"tauri-plugin-vpnservice-api": "workspace:*",
"vue": "^3.5.12",
"vue-router": "^4.4.5"
@@ -32,7 +31,7 @@
"devDependencies": {
"@antfu/eslint-config": "^3.7.3",
"@intlify/unplugin-vue-i18n": "^5.2.0",
"@primevue/auto-import-resolver": "4.3.3",
"@primevue/auto-import-resolver": "4.3.9",
"@tauri-apps/api": "2.7.0",
"@tauri-apps/cli": "2.7.1",
"@types/default-gateway": "^7.2.2",

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "easytier-gui"
version = "2.4.3"
version = "2.4.5"
description = "EasyTier GUI"
authors = ["you"]
edition = "2021"
@@ -52,6 +52,7 @@ tauri-plugin-vpnservice = { path = "../../tauri-plugin-vpnservice" }
tauri-plugin-os = "2.3.0"
tauri-plugin-autostart = "2.5.0"
uuid = "1.17.0"
async-trait = "0.1.89"
[target.'cfg(target_os = "windows")'.dependencies]
windows = { version = "0.52", features = ["Win32_Foundation", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }

View File

@@ -3,41 +3,67 @@
mod elevate;
use std::collections::BTreeMap;
use easytier::proto::api::manage::{
CollectNetworkInfoResponse, ValidateConfigResponse, WebClientService,
WebClientServiceClientFactory,
};
use easytier::rpc_service::remote_client::{
GetNetworkMetasResponse, ListNetworkInstanceIdsJsonResp, ListNetworkProps, RemoteClientManager,
Storage,
};
use easytier::{
common::config::{ConfigLoader, FileLoggerConfig, LoggingConfigBuilder, TomlConfigLoader},
instance_manager::NetworkInstanceManager,
launcher::{ConfigSource, NetworkConfig, NetworkInstanceRunningInfo},
launcher::NetworkConfig,
rpc_service::ApiRpcServer,
tunnel::ring::RingTunnelListener,
utils::{self, NewFilterSender},
};
use std::ops::Deref;
use std::sync::Arc;
use uuid::Uuid;
use tauri::Manager as _;
pub const AUTOSTART_ARG: &str = "--autostart";
use tauri::{AppHandle, Emitter, Manager as _};
#[cfg(not(target_os = "android"))]
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
static INSTANCE_MANAGER: once_cell::sync::Lazy<NetworkInstanceManager> =
once_cell::sync::Lazy::new(NetworkInstanceManager::new);
pub const AUTOSTART_ARG: &str = "--autostart";
static INSTANCE_MANAGER: once_cell::sync::Lazy<Arc<NetworkInstanceManager>> =
once_cell::sync::Lazy::new(|| Arc::new(NetworkInstanceManager::new()));
static mut LOGGER_LEVEL_SENDER: once_cell::sync::Lazy<Option<NewFilterSender>> =
once_cell::sync::Lazy::new(Default::default);
static RPC_RING_UUID: once_cell::sync::Lazy<uuid::Uuid> =
once_cell::sync::Lazy::new(uuid::Uuid::new_v4);
static CLIENT_MANAGER: once_cell::sync::OnceCell<manager::GUIClientManager> =
once_cell::sync::OnceCell::new();
#[tauri::command]
fn easytier_version() -> Result<String, String> {
Ok(easytier::VERSION.to_string())
}
#[tauri::command]
fn is_autostart() -> Result<bool, String> {
let args: Vec<String> = std::env::args().collect();
println!("{:?}", args);
Ok(args.contains(&AUTOSTART_ARG.to_owned()))
fn set_dock_visibility(app: tauri::AppHandle, visible: bool) -> Result<(), String> {
#[cfg(target_os = "macos")]
{
use tauri::ActivationPolicy;
app.set_activation_policy(if visible {
ActivationPolicy::Regular
} else {
ActivationPolicy::Accessory
})
.map_err(|e| e.to_string())?;
}
#[cfg(not(target_os = "macos"))]
let _ = (app, visible);
Ok(())
}
// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command
#[tauri::command]
fn parse_network_config(cfg: NetworkConfig) -> Result<String, String> {
let toml = cfg.gen_config().map_err(|e| e.to_string())?;
@@ -52,46 +78,52 @@ fn generate_network_config(toml_config: String) -> Result<NetworkConfig, String>
}
#[tauri::command]
fn run_network_instance(cfg: NetworkConfig) -> Result<(), String> {
async fn run_network_instance(
app: AppHandle,
cfg: NetworkConfig,
save: bool,
) -> Result<(), String> {
let instance_id = cfg.instance_id().to_string();
let cfg = cfg.gen_config().map_err(|e| e.to_string())?;
INSTANCE_MANAGER
.run_network_instance(cfg, ConfigSource::GUI)
.map_err(|e| e.to_string())?;
println!("instance {} started", instance_id);
Ok(())
}
#[tauri::command]
fn retain_network_instance(instance_ids: Vec<String>) -> Result<(), String> {
let instance_ids = instance_ids
.into_iter()
.filter_map(|id| uuid::Uuid::parse_str(&id).ok())
.collect();
let retained = INSTANCE_MANAGER
.retain_network_instance(instance_ids)
.map_err(|e| e.to_string())?;
println!("instance {:?} retained", retained);
Ok(())
}
#[tauri::command]
fn collect_network_infos() -> Result<BTreeMap<String, NetworkInstanceRunningInfo>, String> {
let infos = INSTANCE_MANAGER
.collect_network_infos()
app.emit("pre_run_network_instance", cfg.instance_id())
.map_err(|e| e.to_string())?;
let mut ret = BTreeMap::new();
for (uuid, info) in infos {
ret.insert(uuid.to_string(), info);
#[cfg(target_os = "android")]
if cfg.no_tun() == false {
CLIENT_MANAGER
.get()
.unwrap()
.disable_instances_with_tun(&app)
.await
.map_err(|e| e.to_string())?;
}
Ok(ret)
CLIENT_MANAGER
.get()
.unwrap()
.handle_run_network_instance(app.clone(), cfg, save)
.await
.map_err(|e| e.to_string())?;
app.emit("post_run_network_instance", instance_id)
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
fn get_os_hostname() -> Result<String, String> {
Ok(gethostname::gethostname().to_string_lossy().to_string())
async fn collect_network_info(
app: AppHandle,
instance_id: String,
) -> Result<CollectNetworkInfoResponse, String> {
let instance_id = instance_id
.parse()
.map_err(|e: uuid::Error| e.to_string())?;
CLIENT_MANAGER
.get()
.unwrap()
.handle_collect_network_info(app, Some(vec![instance_id]))
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
@@ -103,14 +135,143 @@ fn set_logging_level(level: String) -> Result<(), String> {
}
#[tauri::command]
fn set_tun_fd(instance_id: String, fd: i32) -> Result<(), String> {
let uuid = uuid::Uuid::parse_str(&instance_id).map_err(|e| e.to_string())?;
INSTANCE_MANAGER
.set_tun_fd(&uuid, fd)
fn set_tun_fd(fd: i32) -> Result<(), String> {
if let Some(uuid) = CLIENT_MANAGER
.get()
.unwrap()
.get_enabled_instances_with_tun_ids()
.next()
{
INSTANCE_MANAGER
.set_tun_fd(&uuid, fd)
.map_err(|e| e.to_string())?;
}
Ok(())
}
#[tauri::command]
async fn list_network_instance_ids(
app: AppHandle,
) -> Result<ListNetworkInstanceIdsJsonResp, String> {
CLIENT_MANAGER
.get()
.unwrap()
.handle_list_network_instance_ids(app)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn remove_network_instance(app: AppHandle, instance_id: String) -> Result<(), String> {
let instance_id = instance_id
.parse()
.map_err(|e: uuid::Error| e.to_string())?;
CLIENT_MANAGER
.get()
.unwrap()
.handle_remove_network_instances(app.clone(), vec![instance_id])
.await
.map_err(|e| e.to_string())?;
CLIENT_MANAGER
.get()
.unwrap()
.notify_vpn_stop_if_no_tun(&app)?;
Ok(())
}
#[tauri::command]
async fn update_network_config_state(
app: AppHandle,
instance_id: String,
disabled: bool,
) -> Result<(), String> {
let instance_id = instance_id
.parse()
.map_err(|e: uuid::Error| e.to_string())?;
CLIENT_MANAGER
.get()
.unwrap()
.handle_update_network_state(app.clone(), instance_id, disabled)
.await
.map_err(|e| e.to_string())?;
if disabled {
CLIENT_MANAGER
.get()
.unwrap()
.notify_vpn_stop_if_no_tun(&app)?;
}
Ok(())
}
#[tauri::command]
async fn save_network_config(app: AppHandle, cfg: NetworkConfig) -> Result<(), String> {
let instance_id = cfg
.instance_id()
.parse()
.map_err(|e: uuid::Error| e.to_string())?;
CLIENT_MANAGER
.get()
.unwrap()
.handle_save_network_config(app, instance_id, cfg)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn validate_config(
app: AppHandle,
config: NetworkConfig,
) -> Result<ValidateConfigResponse, String> {
CLIENT_MANAGER
.get()
.unwrap()
.handle_validate_config(app, config)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn get_config(app: AppHandle, instance_id: String) -> Result<NetworkConfig, String> {
let cfg = CLIENT_MANAGER
.get()
.unwrap()
.storage
.get_network_config(app, &instance_id)
.await
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("Config not found for instance ID: {}", instance_id))?;
Ok(cfg.1)
}
#[tauri::command]
async fn load_configs(
app: AppHandle,
configs: Vec<NetworkConfig>,
enabled_networks: Vec<String>,
) -> Result<(), String> {
CLIENT_MANAGER
.get()
.unwrap()
.storage
.load_configs(app, configs, enabled_networks)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
async fn get_network_metas(
app: AppHandle,
instance_ids: Vec<uuid::Uuid>,
) -> Result<GetNetworkMetasResponse, String> {
CLIENT_MANAGER
.get()
.unwrap()
.handle_get_network_metas(app, instance_ids)
.await
.map_err(|e| e.to_string())
}
#[cfg(not(target_os = "android"))]
fn toggle_window_visibility<R: tauri::Runtime>(app: &tauri::AppHandle<R>) {
if let Some(window) = app.get_webview_window("main") {
@@ -148,6 +309,274 @@ fn check_sudo() -> bool {
is_elevated
}
mod manager {
use super::*;
use async_trait::async_trait;
use dashmap::{DashMap, DashSet};
use easytier::launcher::NetworkConfig;
use easytier::proto::api::manage::RunNetworkInstanceRequest;
use easytier::proto::rpc_impl::bidirect::BidirectRpcManager;
use easytier::proto::rpc_types::controller::BaseController;
use easytier::rpc_service::remote_client::PersistentConfig;
use easytier::tunnel::ring::RingTunnelConnector;
use easytier::tunnel::TunnelConnector;
#[derive(Clone)]
pub(super) struct GUIConfig(String, pub(crate) NetworkConfig);
impl PersistentConfig<anyhow::Error> for GUIConfig {
fn get_network_inst_id(&self) -> &str {
&self.0
}
fn get_network_config(&self) -> Result<NetworkConfig, anyhow::Error> {
Ok(self.1.clone())
}
}
pub(super) struct GUIStorage {
network_configs: DashMap<Uuid, GUIConfig>,
enabled_networks: DashSet<Uuid>,
}
impl GUIStorage {
fn new() -> Self {
Self {
network_configs: DashMap::new(),
enabled_networks: DashSet::new(),
}
}
pub(super) async fn load_configs(
&self,
app: AppHandle,
configs: Vec<NetworkConfig>,
enabled_networks: Vec<String>,
) -> anyhow::Result<()> {
self.network_configs.clear();
for cfg in configs {
let instance_id = cfg.instance_id();
self.network_configs.insert(
instance_id.parse()?,
GUIConfig(instance_id.to_string(), cfg),
);
}
self.enabled_networks.clear();
INSTANCE_MANAGER.iter().for_each(|v| {
self.enabled_networks.insert(*v.key());
});
for id in enabled_networks {
if let Ok(uuid) = id.parse() {
if !self.enabled_networks.contains(&uuid) {
let config = self
.network_configs
.get(&uuid)
.map(|i| i.value().1.clone())
.ok_or_else(|| anyhow::anyhow!("Config not found"))?;
CLIENT_MANAGER
.get()
.unwrap()
.get_rpc_client(app.clone())
.ok_or_else(|| anyhow::anyhow!("RPC client not found"))?
.run_network_instance(
BaseController::default(),
RunNetworkInstanceRequest {
inst_id: None,
config: Some(config),
overwrite: false,
},
)
.await?;
self.enabled_networks.insert(uuid);
}
}
}
Ok(())
}
fn save_configs(&self, app: &AppHandle) -> anyhow::Result<()> {
let configs: Result<Vec<String>, _> = self
.network_configs
.iter()
.map(|entry| serde_json::to_string(&entry.value().1))
.collect();
let payload = format!("[{}]", configs?.join(","));
app.emit_str("save_configs", payload)?;
Ok(())
}
fn save_enabled_networks(&self, app: &AppHandle) -> anyhow::Result<()> {
let payload: Vec<String> = self
.enabled_networks
.iter()
.map(|entry| entry.key().to_string())
.collect();
app.emit("save_enabled_networks", payload)?;
Ok(())
}
fn save_config(
&self,
app: &AppHandle,
inst_id: Uuid,
cfg: NetworkConfig,
) -> anyhow::Result<()> {
let config = GUIConfig(inst_id.to_string(), cfg);
self.network_configs.insert(inst_id, config);
self.save_configs(app)
}
}
#[async_trait]
impl Storage<AppHandle, GUIConfig, anyhow::Error> for GUIStorage {
async fn insert_or_update_user_network_config(
&self,
app: AppHandle,
network_inst_id: Uuid,
network_config: NetworkConfig,
) -> Result<(), anyhow::Error> {
self.save_config(&app, network_inst_id, network_config)?;
self.enabled_networks.insert(network_inst_id);
self.save_enabled_networks(&app)?;
Ok(())
}
async fn delete_network_configs(
&self,
app: AppHandle,
network_inst_ids: &[Uuid],
) -> Result<(), anyhow::Error> {
for network_inst_id in network_inst_ids {
self.network_configs.remove(network_inst_id);
self.enabled_networks.remove(network_inst_id);
}
self.save_configs(&app)
}
async fn update_network_config_state(
&self,
app: AppHandle,
network_inst_id: Uuid,
disabled: bool,
) -> Result<(), anyhow::Error> {
if disabled {
self.enabled_networks.remove(&network_inst_id);
} else {
self.enabled_networks.insert(network_inst_id);
}
self.save_enabled_networks(&app)?;
Ok(())
}
async fn list_network_configs(
&self,
_: AppHandle,
props: ListNetworkProps,
) -> Result<Vec<GUIConfig>, anyhow::Error> {
let mut ret = Vec::new();
for entry in self.network_configs.iter() {
let id: Uuid = entry.key().to_owned();
match props {
ListNetworkProps::All => {
ret.push(entry.value().clone());
}
ListNetworkProps::EnabledOnly => {
if self.enabled_networks.contains(&id) {
ret.push(entry.value().clone());
}
}
ListNetworkProps::DisabledOnly => {
if !self.enabled_networks.contains(&id) {
ret.push(entry.value().clone());
}
}
}
}
Ok(ret)
}
async fn get_network_config(
&self,
_: AppHandle,
network_inst_id: &str,
) -> Result<Option<GUIConfig>, anyhow::Error> {
let uuid = Uuid::parse_str(network_inst_id)?;
Ok(self
.network_configs
.get(&uuid)
.map(|entry| entry.value().clone()))
}
}
pub(super) struct GUIClientManager {
pub(super) storage: GUIStorage,
rpc_manager: BidirectRpcManager,
}
impl GUIClientManager {
pub async fn new() -> Result<Self, anyhow::Error> {
let mut connector = RingTunnelConnector::new(
format!("ring://{}", RPC_RING_UUID.deref()).parse().unwrap(),
);
let tunnel = connector.connect().await?;
let rpc_manager = BidirectRpcManager::new();
rpc_manager.run_with_tunnel(tunnel);
Ok(Self {
storage: GUIStorage::new(),
rpc_manager,
})
}
pub fn get_enabled_instances_with_tun_ids(&self) -> impl Iterator<Item = uuid::Uuid> + '_ {
self.storage
.network_configs
.iter()
.filter(|v| self.storage.enabled_networks.contains(v.key()))
.filter(|v| !v.1.no_tun())
.filter_map(|c| c.1.instance_id().parse::<uuid::Uuid>().ok())
}
#[cfg(target_os = "android")]
pub(super) async fn disable_instances_with_tun(
&self,
app: &AppHandle,
) -> Result<(), easytier::rpc_service::remote_client::RemoteClientError<anyhow::Error>>
{
for inst_id in self.get_enabled_instances_with_tun_ids() {
self.handle_update_network_state(app.clone(), inst_id, true)
.await?;
}
Ok(())
}
pub(super) fn notify_vpn_stop_if_no_tun(&self, app: &AppHandle) -> Result<(), String> {
let has_tun = self.get_enabled_instances_with_tun_ids().any(|_| true);
if !has_tun {
app.emit("vpn_service_stop", "")
.map_err(|e| e.to_string())?;
}
Ok(())
}
}
impl RemoteClientManager<AppHandle, GUIConfig, anyhow::Error> for GUIClientManager {
fn get_rpc_client(
&self,
_: AppHandle,
) -> Option<Box<dyn WebClientService<Controller = BaseController> + Send>> {
Some(
self.rpc_manager
.rpc_client()
.scoped_client::<WebClientServiceClientFactory<BaseController>>(
1,
1,
"".to_string(),
),
)
}
fn get_storage(&self) -> &impl Storage<AppHandle, GUIConfig, anyhow::Error> {
&self.storage
}
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
#[cfg(not(target_os = "android"))]
@@ -158,6 +587,24 @@ pub fn run() {
utils::setup_panic_handler();
let _rpc_server_handle = tauri::async_runtime::spawn(async move {
let rpc_server = ApiRpcServer::from_tunnel(
RingTunnelListener::new(format!("ring://{}", RPC_RING_UUID.deref()).parse().unwrap()),
INSTANCE_MANAGER.clone(),
)
.serve()
.await
.expect("Failed to start RPC server");
let _ = CLIENT_MANAGER.set(
manager::GUIClientManager::new()
.await
.expect("Failed to create GUI client manager"),
);
rpc_server
});
let mut builder = tauri::Builder::default();
#[cfg(not(target_os = "android"))]
@@ -199,6 +646,8 @@ pub fn run() {
dir: Some(log_dir.to_string_lossy().to_string()),
level: None,
file: None,
size_mb: None,
count: None,
})
.build()
.map_err(|e| e.to_string())?;
@@ -237,13 +686,19 @@ pub fn run() {
parse_network_config,
generate_network_config,
run_network_instance,
retain_network_instance,
collect_network_infos,
get_os_hostname,
collect_network_info,
set_logging_level,
set_tun_fd,
is_autostart,
easytier_version
easytier_version,
set_dock_visibility,
list_network_instance_ids,
remove_network_instance,
update_network_config_state,
save_network_config,
validate_config,
get_config,
load_configs,
get_network_metas,
])
.on_window_event(|_win, event| match event {
#[cfg(not(target_os = "android"))]

View File

@@ -17,9 +17,13 @@
"createUpdaterArtifacts": false
},
"productName": "easytier-gui",
"version": "2.4.3",
"version": "2.4.5",
"identifier": "com.kkrainbow.easytier",
"plugins": {},
"plugins": {
"shell": {
"open": "^.+"
}
},
"app": {
"windows": [
{

View File

@@ -9,36 +9,34 @@ declare global {
const EffectScope: typeof import('vue')['EffectScope']
const MenuItemExit: typeof import('./composables/tray')['MenuItemExit']
const MenuItemShow: typeof import('./composables/tray')['MenuItemShow']
const ReinitTray: typeof import('./composables/tray')['ReinitTray']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const collectNetworkInfos: typeof import('./composables/network')['collectNetworkInfos']
const collectNetworkInfo: typeof import('./composables/backend')['collectNetworkInfo']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const createPinia: typeof import('pinia')['createPinia']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const definePage: typeof import('unplugin-vue-router/runtime')['definePage']
const defineStore: typeof import('pinia')['defineStore']
const deleteNetworkInstance: typeof import('./composables/backend')['deleteNetworkInstance']
const effectScope: typeof import('vue')['effectScope']
const event2human: typeof import('./composables/utils')['event2human']
const generateMenuItem: typeof import('./composables/tray')['generateMenuItem']
const generateNetworkConfig: typeof import('./composables/network')['generateNetworkConfig']
const generateNetworkConfig: typeof import('./composables/backend')['generateNetworkConfig']
const getActivePinia: typeof import('pinia')['getActivePinia']
const getConfig: typeof import('./composables/backend')['getConfig']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const getEasytierVersion: typeof import('./composables/network')['getEasytierVersion']
const getOsHostname: typeof import('./composables/network')['getOsHostname']
const getEasytierVersion: typeof import('./composables/backend')['getEasytierVersion']
const getNetworkMetas: typeof import('./composables/backend')['getNetworkMetas']
const h: typeof import('vue')['h']
const initMobileService: typeof import('./composables/mobile_vpn')['initMobileService']
const initMobileVpnService: typeof import('./composables/mobile_vpn')['initMobileVpnService']
const inject: typeof import('vue')['inject']
const isAutostart: typeof import('./composables/network')['isAutostart']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const loadRunningInstanceIdsFromLocalStorage: typeof import('./stores/network')['loadRunningInstanceIdsFromLocalStorage']
const listNetworkInstanceIds: typeof import('./composables/backend')['listNetworkInstanceIds']
const listenGlobalEvents: typeof import('./composables/event')['listenGlobalEvents']
const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
const mapState: typeof import('pinia')['mapState']
@@ -46,8 +44,6 @@ declare global {
const mapWritableState: typeof import('pinia')['mapWritableState']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const num2ipv4: typeof import('./composables/utils')['num2ipv4']
const num2ipv6: typeof import('./composables/utils')['num2ipv6']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
@@ -57,6 +53,7 @@ declare global {
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onNetworkInstanceChange: typeof import('./composables/mobile_vpn')['onNetworkInstanceChange']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
@@ -64,34 +61,34 @@ declare global {
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const parseNetworkConfig: typeof import('./composables/network')['parseNetworkConfig']
const parseNetworkConfig: typeof import('./composables/backend')['parseNetworkConfig']
const prepareVpnService: typeof import('./composables/mobile_vpn')['prepareVpnService']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const retainNetworkInstance: typeof import('./composables/network')['retainNetworkInstance']
const runNetworkInstance: typeof import('./composables/network')['runNetworkInstance']
const runNetworkInstance: typeof import('./composables/backend')['runNetworkInstance']
const saveNetworkConfig: typeof import('./composables/backend')['saveNetworkConfig']
const sendConfigs: typeof import('./composables/backend')['sendConfigs']
const setActivePinia: typeof import('pinia')['setActivePinia']
const setAutoLaunchStatus: typeof import('./composables/network')['setAutoLaunchStatus']
const setLoggingLevel: typeof import('./composables/network')['setLoggingLevel']
const setLoggingLevel: typeof import('./composables/backend')['setLoggingLevel']
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
const setTrayMenu: typeof import('./composables/tray')['setTrayMenu']
const setTrayRunState: typeof import('./composables/tray')['setTrayRunState']
const setTrayTooltip: typeof import('./composables/tray')['setTrayTooltip']
const setTunFd: typeof import('./composables/network')['setTunFd']
const setTunFd: typeof import('./composables/backend')['setTunFd']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const storeToRefs: typeof import('pinia')['storeToRefs']
const timeAgoCn: typeof import('./composables/utils')['timeAgoCn']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const updateNetworkConfigState: typeof import('./composables/backend')['updateNetworkConfigState']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
@@ -99,12 +96,12 @@ declare global {
const useId: typeof import('vue')['useId']
const useLink: typeof import('vue-router/auto')['useLink']
const useModel: typeof import('vue')['useModel']
const useNetworkStore: typeof import('./stores/network')['useNetworkStore']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSlots: typeof import('vue')['useSlots']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const useTray: typeof import('./composables/tray')['useTray']
const validateConfig: typeof import('./composables/backend')['validateConfig']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
@@ -116,6 +113,7 @@ declare global {
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}
// for vue template auto import
import { UnwrapRef } from 'vue'
declare module 'vue' {
@@ -125,7 +123,7 @@ declare module 'vue' {
readonly MenuItemExit: UnwrapRef<typeof import('./composables/tray')['MenuItemExit']>
readonly MenuItemShow: UnwrapRef<typeof import('./composables/tray')['MenuItemShow']>
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
readonly collectNetworkInfos: UnwrapRef<typeof import('./composables/network')['collectNetworkInfos']>
readonly collectNetworkInfo: UnwrapRef<typeof import('./composables/backend')['collectNetworkInfo']>
readonly computed: UnwrapRef<typeof import('vue')['computed']>
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
readonly createPinia: UnwrapRef<typeof import('pinia')['createPinia']>
@@ -133,22 +131,25 @@ declare module 'vue' {
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
readonly defineStore: UnwrapRef<typeof import('pinia')['defineStore']>
readonly deleteNetworkInstance: UnwrapRef<typeof import('./composables/backend')['deleteNetworkInstance']>
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
readonly generateMenuItem: UnwrapRef<typeof import('./composables/tray')['generateMenuItem']>
readonly generateNetworkConfig: UnwrapRef<typeof import('./composables/network')['generateNetworkConfig']>
readonly generateNetworkConfig: UnwrapRef<typeof import('./composables/backend')['generateNetworkConfig']>
readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']>
readonly getConfig: UnwrapRef<typeof import('./composables/backend')['getConfig']>
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
readonly getEasytierVersion: UnwrapRef<typeof import('./composables/network')['getEasytierVersion']>
readonly getOsHostname: UnwrapRef<typeof import('./composables/network')['getOsHostname']>
readonly getEasytierVersion: UnwrapRef<typeof import('./composables/backend')['getEasytierVersion']>
readonly getNetworkMetas: UnwrapRef<typeof import('./composables/backend')['getNetworkMetas']>
readonly h: UnwrapRef<typeof import('vue')['h']>
readonly initMobileVpnService: UnwrapRef<typeof import('./composables/mobile_vpn')['initMobileVpnService']>
readonly inject: UnwrapRef<typeof import('vue')['inject']>
readonly isAutostart: UnwrapRef<typeof import('./composables/network')['isAutostart']>
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
readonly listNetworkInstanceIds: UnwrapRef<typeof import('./composables/backend')['listNetworkInstanceIds']>
readonly listenGlobalEvents: UnwrapRef<typeof import('./composables/event')['listenGlobalEvents']>
readonly mapActions: UnwrapRef<typeof import('pinia')['mapActions']>
readonly mapGetters: UnwrapRef<typeof import('pinia')['mapGetters']>
readonly mapState: UnwrapRef<typeof import('pinia')['mapState']>
@@ -165,6 +166,7 @@ declare module 'vue' {
readonly onDeactivated: UnwrapRef<typeof import('vue')['onDeactivated']>
readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']>
readonly onMounted: UnwrapRef<typeof import('vue')['onMounted']>
readonly onNetworkInstanceChange: UnwrapRef<typeof import('./composables/mobile_vpn')['onNetworkInstanceChange']>
readonly onRenderTracked: UnwrapRef<typeof import('vue')['onRenderTracked']>
readonly onRenderTriggered: UnwrapRef<typeof import('vue')['onRenderTriggered']>
readonly onScopeDispose: UnwrapRef<typeof import('vue')['onScopeDispose']>
@@ -172,22 +174,23 @@ declare module 'vue' {
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
readonly onWatcherCleanup: UnwrapRef<typeof import('vue')['onWatcherCleanup']>
readonly parseNetworkConfig: UnwrapRef<typeof import('./composables/network')['parseNetworkConfig']>
readonly parseNetworkConfig: UnwrapRef<typeof import('./composables/backend')['parseNetworkConfig']>
readonly prepareVpnService: UnwrapRef<typeof import('./composables/mobile_vpn')['prepareVpnService']>
readonly provide: UnwrapRef<typeof import('vue')['provide']>
readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
readonly readonly: UnwrapRef<typeof import('vue')['readonly']>
readonly ref: UnwrapRef<typeof import('vue')['ref']>
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
readonly retainNetworkInstance: UnwrapRef<typeof import('./composables/network')['retainNetworkInstance']>
readonly runNetworkInstance: UnwrapRef<typeof import('./composables/network')['runNetworkInstance']>
readonly runNetworkInstance: UnwrapRef<typeof import('./composables/backend')['runNetworkInstance']>
readonly saveNetworkConfig: UnwrapRef<typeof import('./composables/backend')['saveNetworkConfig']>
readonly sendConfigs: UnwrapRef<typeof import('./composables/backend')['sendConfigs']>
readonly setActivePinia: UnwrapRef<typeof import('pinia')['setActivePinia']>
readonly setLoggingLevel: UnwrapRef<typeof import('./composables/network')['setLoggingLevel']>
readonly setLoggingLevel: UnwrapRef<typeof import('./composables/backend')['setLoggingLevel']>
readonly setMapStoreSuffix: UnwrapRef<typeof import('pinia')['setMapStoreSuffix']>
readonly setTrayMenu: UnwrapRef<typeof import('./composables/tray')['setTrayMenu']>
readonly setTrayRunState: UnwrapRef<typeof import('./composables/tray')['setTrayRunState']>
readonly setTrayTooltip: UnwrapRef<typeof import('./composables/tray')['setTrayTooltip']>
readonly setTunFd: UnwrapRef<typeof import('./composables/network')['setTunFd']>
readonly setTunFd: UnwrapRef<typeof import('./composables/backend')['setTunFd']>
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
@@ -198,6 +201,7 @@ declare module 'vue' {
readonly toValue: UnwrapRef<typeof import('vue')['toValue']>
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
readonly unref: UnwrapRef<typeof import('vue')['unref']>
readonly updateNetworkConfigState: UnwrapRef<typeof import('./composables/backend')['updateNetworkConfigState']>
readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']>
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>
@@ -205,15 +209,15 @@ declare module 'vue' {
readonly useId: UnwrapRef<typeof import('vue')['useId']>
readonly useLink: UnwrapRef<typeof import('vue-router/auto')['useLink']>
readonly useModel: UnwrapRef<typeof import('vue')['useModel']>
readonly useNetworkStore: UnwrapRef<typeof import('./stores/network')['useNetworkStore']>
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']>
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
readonly useTemplateRef: UnwrapRef<typeof import('vue')['useTemplateRef']>
readonly useTray: UnwrapRef<typeof import('./composables/tray')['useTray']>
readonly validateConfig: UnwrapRef<typeof import('./composables/backend')['validateConfig']>
readonly watch: UnwrapRef<typeof import('vue')['watch']>
readonly watchEffect: UnwrapRef<typeof import('vue')['watchEffect']>
readonly watchPostEffect: UnwrapRef<typeof import('vue')['watchPostEffect']>
readonly watchSyncEffect: UnwrapRef<typeof import('vue')['watchSyncEffect']>
}
}
}

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { getEasytierVersion } from '~/composables/network'
import { getEasytierVersion } from '~/composables/backend'
const { t } = useI18n()

View File

@@ -0,0 +1,70 @@
import { invoke } from '@tauri-apps/api/core'
import { Api, type NetworkTypes } from 'easytier-frontend-lib'
import { GetNetworkMetasResponse } from 'node_modules/easytier-frontend-lib/dist/modules/api'
import { getAutoLaunchStatusAsync } from '~/modules/auto_launch'
type NetworkConfig = NetworkTypes.NetworkConfig
type ValidateConfigResponse = Api.ValidateConfigResponse
type ListNetworkInstanceIdResponse = Api.ListNetworkInstanceIdResponse
export async function parseNetworkConfig(cfg: NetworkConfig) {
return invoke<string>('parse_network_config', { cfg })
}
export async function generateNetworkConfig(tomlConfig: string) {
return invoke<NetworkConfig>('generate_network_config', { tomlConfig })
}
export async function runNetworkInstance(cfg: NetworkConfig, save: boolean) {
return invoke('run_network_instance', { cfg, save })
}
export async function collectNetworkInfo(instanceId: string) {
return await invoke<Api.CollectNetworkInfoResponse>('collect_network_info', { instanceId })
}
export async function setLoggingLevel(level: string) {
return await invoke('set_logging_level', { level })
}
export async function setTunFd(fd: number) {
return await invoke('set_tun_fd', { fd })
}
export async function getEasytierVersion() {
return await invoke<string>('easytier_version')
}
export async function listNetworkInstanceIds() {
return await invoke<ListNetworkInstanceIdResponse>('list_network_instance_ids')
}
export async function deleteNetworkInstance(instanceId: string) {
return await invoke('remove_network_instance', { instanceId })
}
export async function updateNetworkConfigState(instanceId: string, disabled: boolean) {
return await invoke('update_network_config_state', { instanceId, disabled })
}
export async function saveNetworkConfig(cfg: NetworkConfig) {
return await invoke('save_network_config', { cfg })
}
export async function validateConfig(cfg: NetworkConfig) {
return await invoke<ValidateConfigResponse>('validate_config', { cfg })
}
export async function getConfig(instanceId: string) {
return await invoke<NetworkConfig>('get_config', { instanceId })
}
export async function sendConfigs() {
let networkList: NetworkConfig[] = JSON.parse(localStorage.getItem('networkList') || '[]');
let autoStartInstIds = getAutoLaunchStatusAsync() ? JSON.parse(localStorage.getItem('autoStartInstIds') || '[]') : []
return await invoke('load_configs', { configs: networkList, enabledNetworks: autoStartInstIds })
}
export async function getNetworkMetas(instanceIds: string[]) {
return await invoke<GetNetworkMetasResponse>('get_network_metas', { instanceIds })
}

View File

@@ -0,0 +1,51 @@
import { Event, listen } from "@tauri-apps/api/event";
import { type } from "@tauri-apps/plugin-os";
import { NetworkTypes } from "easytier-frontend-lib"
const EVENTS = Object.freeze({
SAVE_CONFIGS: 'save_configs',
SAVE_ENABLED_NETWORKS: 'save_enabled_networks',
PRE_RUN_NETWORK_INSTANCE: 'pre_run_network_instance',
POST_RUN_NETWORK_INSTANCE: 'post_run_network_instance',
VPN_SERVICE_STOP: 'vpn_service_stop',
});
function onSaveConfigs(event: Event<NetworkTypes.NetworkConfig[]>) {
console.log(`Received event '${EVENTS.SAVE_CONFIGS}': ${event.payload}`);
localStorage.setItem('networkList', JSON.stringify(event.payload));
}
function onSaveEnabledNetworks(event: Event<string[]>) {
console.log(`Received event '${EVENTS.SAVE_ENABLED_NETWORKS}': ${event.payload}`);
localStorage.setItem('autoStartInstIds', JSON.stringify(event.payload));
}
async function onPreRunNetworkInstance(event: Event<string>) {
if (type() === 'android') {
await prepareVpnService(event.payload);
}
}
async function onPostRunNetworkInstance(event: Event<string>) {
if (type() === 'android') {
await onNetworkInstanceChange(event.payload);
}
}
async function onVpnServiceStop(event: Event<string>) {
await onNetworkInstanceChange(event.payload);
}
export async function listenGlobalEvents() {
const unlisteners = [
await listen(EVENTS.SAVE_CONFIGS, onSaveConfigs),
await listen(EVENTS.SAVE_ENABLED_NETWORKS, onSaveEnabledNetworks),
await listen(EVENTS.PRE_RUN_NETWORK_INSTANCE, onPreRunNetworkInstance),
await listen(EVENTS.POST_RUN_NETWORK_INSTANCE, onPostRunNetworkInstance),
await listen(EVENTS.VPN_SERVICE_STOP, onVpnServiceStop),
];
return () => {
unlisteners.forEach(unlisten => unlisten());
};
}

View File

@@ -5,8 +5,6 @@ import { prepare_vpn, start_vpn, stop_vpn } from 'tauri-plugin-vpnservice-api'
type Route = NetworkTypes.Route
const networkStore = useNetworkStore()
interface vpnStatus {
running: boolean
ipv4Addr: string | null | undefined
@@ -69,7 +67,7 @@ async function onVpnServiceStart(payload: any) {
console.log('vpn service start', JSON.stringify(payload))
curVpnStatus.running = true
if (payload.fd) {
setTunFd(networkStore.networkInstanceIds[0], payload.fd)
setTunFd(payload.fd)
}
}
@@ -93,7 +91,7 @@ async function registerVpnServiceListener() {
)
}
function getRoutesForVpn(routes: Route[]): string[] {
function getRoutesForVpn(routes: Route[], node_config: NetworkTypes.NetworkConfig): string[] {
if (!routes) {
return []
}
@@ -108,24 +106,25 @@ function getRoutesForVpn(routes: Route[]): string[] {
}
}
node_config.routes.forEach(r => {
ret.push(r)
})
// sort and dedup
return Array.from(new Set(ret)).sort()
}
async function onNetworkInstanceChange() {
console.error('vpn service watch network instance change ids', JSON.stringify(networkStore.networkInstanceIds))
const insts = networkStore.networkInstanceIds
const no_tun = networkStore.isNoTunEnabled(insts[0])
if (no_tun) {
export async function onNetworkInstanceChange(instanceId: string) {
console.error('vpn service network instance change id', instanceId)
if (!instanceId) {
await doStopVpn()
return
}
if (!insts) {
await doStopVpn()
const config = await getConfig(instanceId)
if (config.no_tun) {
return
}
const curNetworkInfo = networkStore.networkInfos[insts[0]]
const curNetworkInfo = (await collectNetworkInfo(instanceId)).info.map[instanceId]
if (!curNetworkInfo || curNetworkInfo?.error_msg?.length) {
await doStopVpn()
return
@@ -142,7 +141,7 @@ async function onNetworkInstanceChange() {
network_length = 24
}
const routes = getRoutesForVpn(curNetworkInfo?.routes)
const routes = getRoutesForVpn(curNetworkInfo?.routes, config)
const ipChanged = virtual_ip !== curVpnStatus.ipv4Addr
const routesChanged = JSON.stringify(routes) !== JSON.stringify(curVpnStatus.routes)
@@ -160,48 +159,25 @@ async function onNetworkInstanceChange() {
await doStartVpn(virtual_ip, 24, routes)
}
catch (e) {
console.error('start vpn service failed, clear all network insts.', e)
networkStore.clearNetworkInstances()
await retainNetworkInstance(networkStore.networkInstanceIds)
console.error('start vpn service failed, stop all other network insts.', e)
await runNetworkInstance(config, true); //on android config should always be saved
}
}
}
async function watchNetworkInstance() {
let subscribe_running = false
networkStore.$subscribe(async () => {
if (subscribe_running) {
return
}
subscribe_running = true
try {
await onNetworkInstanceChange()
}
catch (_) {
}
subscribe_running = false
})
console.error('vpn service watch network instance')
}
function isNoTunEnabled(instanceId: string | undefined) {
async function isNoTunEnabled(instanceId: string | undefined) {
if (!instanceId) {
return false
}
const no_tun = networkStore.isNoTunEnabled(instanceId)
if (no_tun) {
return true
}
return false
return (await getConfig(instanceId)).no_tun ?? false
}
export async function initMobileVpnService() {
await registerVpnServiceListener()
await watchNetworkInstance()
}
export async function prepareVpnService(instanceId: string) {
if (isNoTunEnabled(instanceId)) {
if (await isNoTunEnabled(instanceId)) {
return
}
console.log('prepare vpn')

View File

@@ -1,45 +0,0 @@
import type { NetworkTypes } from 'easytier-frontend-lib'
import { invoke } from '@tauri-apps/api/core'
type NetworkConfig = NetworkTypes.NetworkConfig
type NetworkInstanceRunningInfo = NetworkTypes.NetworkInstanceRunningInfo
export async function parseNetworkConfig(cfg: NetworkConfig) {
return invoke<string>('parse_network_config', { cfg })
}
export async function generateNetworkConfig(tomlConfig: string) {
return invoke<NetworkConfig>('generate_network_config', { tomlConfig })
}
export async function runNetworkInstance(cfg: NetworkConfig) {
return invoke('run_network_instance', { cfg })
}
export async function retainNetworkInstance(instanceIds: string[]) {
return invoke('retain_network_instance', { instanceIds })
}
export async function collectNetworkInfos() {
return await invoke<Record<string, NetworkInstanceRunningInfo>>('collect_network_infos')
}
export async function getOsHostname() {
return await invoke<string>('get_os_hostname')
}
export async function isAutostart() {
return await invoke<boolean>('is_autostart')
}
export async function setLoggingLevel(level: string) {
return await invoke('set_logging_level', { level })
}
export async function setTunFd(instanceId: string, fd: number) {
return await invoke('set_tun_fd', { instanceId, fd })
}
export async function getEasytierVersion() {
return await invoke<string>('easytier_version')
}

View File

@@ -1,15 +1,15 @@
import Aura from '@primevue/themes/aura'
import PrimeVue from 'primevue/config'
import ToastService from 'primevue/toastservice'
import Aura from '@primeuix/themes/aura';
import PrimeVue from 'primevue/config';
import { createRouter, createWebHistory } from 'vue-router/auto'
import { routes } from 'vue-router/auto-routes'
import App from '~/App.vue'
import EasyTierFrontendLib, { I18nUtils } from 'easytier-frontend-lib'
import EasyTierFrontendLib, { I18nUtils } from 'easytier-frontend-lib';
import { createRouter, createWebHistory } from 'vue-router/auto';
import { routes } from 'vue-router/auto-routes';
import App from '~/App.vue';
import { getAutoLaunchStatusAsync, loadAutoLaunchStatusAsync } from './modules/auto_launch'
import '~/styles.css'
import 'easytier-frontend-lib/style.css'
import 'easytier-frontend-lib/style.css';
import { ConfirmationService, DialogService, ToastService } from 'primevue';
import '~/styles.css';
import { getAutoLaunchStatusAsync, loadAutoLaunchStatusAsync } from './modules/auto_launch';
if (import.meta.env.PROD) {
document.addEventListener('keydown', (event) => {
@@ -55,7 +55,9 @@ async function main() {
},
},
})
app.use(ToastService as any)
app.use(ToastService)
app.use(DialogService)
app.use(ConfirmationService)
app.mount('#app')
}

View File

@@ -0,0 +1,47 @@
import { type Api, type NetworkTypes } from "easytier-frontend-lib";
import * as backend from "~/composables/backend";
export class GUIRemoteClient implements Api.RemoteClient {
async validate_config(config: NetworkTypes.NetworkConfig): Promise<Api.ValidateConfigResponse> {
return backend.validateConfig(config);
}
async run_network(config: NetworkTypes.NetworkConfig, save: boolean): Promise<undefined> {
await backend.runNetworkInstance(config, save);
}
async get_network_info(inst_id: string): Promise<NetworkTypes.NetworkInstanceRunningInfo | undefined> {
return backend.collectNetworkInfo(inst_id).then(infos => infos.info.map[inst_id]);
}
async list_network_instance_ids(): Promise<Api.ListNetworkInstanceIdResponse> {
return backend.listNetworkInstanceIds();
}
async delete_network(inst_id: string): Promise<undefined> {
await backend.deleteNetworkInstance(inst_id);
}
async update_network_instance_state(inst_id: string, disabled: boolean): Promise<undefined> {
await backend.updateNetworkConfigState(inst_id, disabled);
}
async save_config(config: NetworkTypes.NetworkConfig): Promise<undefined> {
await backend.saveNetworkConfig(config);
}
async get_network_config(inst_id: string): Promise<NetworkTypes.NetworkConfig> {
return backend.getConfig(inst_id);
}
async generate_config(config: NetworkTypes.NetworkConfig): Promise<Api.GenerateConfigResponse> {
try {
return { toml_config: await backend.parseNetworkConfig(config) };
} catch (e) {
return { error: e + "" };
}
}
async parse_config(toml_config: string): Promise<Api.ParseConfigResponse> {
try {
return { config: await backend.generateNetworkConfig(toml_config) }
} catch (e) {
return { error: e + "" };
}
}
async get_network_metas(instance_ids: string[]): Promise<Api.GetNetworkMetasResponse> {
return await backend.getNetworkMetas(instance_ids);
}
}

View File

@@ -0,0 +1,18 @@
import { invoke } from '@tauri-apps/api/core'
export async function loadDockVisibilityAsync(visible: boolean): Promise<boolean> {
try {
await invoke('set_dock_visibility', { visible })
localStorage.setItem('dock_visibility', JSON.stringify(visible))
return visible
}
catch (e) {
console.error('Failed to set dock visibility:', e)
return getDockVisibilityStatus()
}
}
export function getDockVisibilityStatus(): boolean {
const stored = localStorage.getItem('dock_visibility')
return stored !== null ? JSON.parse(stored) : true
}

View File

@@ -1,147 +1,26 @@
<script setup lang="ts">
import { appLogDir } from '@tauri-apps/api/path'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { writeText } from '@tauri-apps/plugin-clipboard-manager'
import { type } from '@tauri-apps/plugin-os'
import { exit } from '@tauri-apps/plugin-process'
import { open } from '@tauri-apps/plugin-shell'
import TieredMenu from 'primevue/tieredmenu'
import { useToast } from 'primevue/usetoast'
import { NetworkTypes, Config, Status, Utils, I18nUtils, ConfigEditDialog } from 'easytier-frontend-lib'
import { isAutostart, setLoggingLevel } from '~/composables/network'
import { appLogDir } from '@tauri-apps/api/path'
import { writeText } from '@tauri-apps/plugin-clipboard-manager'
import { exit } from '@tauri-apps/plugin-process'
import { I18nUtils, RemoteManagement } from "easytier-frontend-lib"
import type { MenuItem } from 'primevue/menuitem'
import { useTray } from '~/composables/tray'
import { GUIRemoteClient } from '~/modules/api'
import { getAutoLaunchStatusAsync as getAutoLaunchStatus, loadAutoLaunchStatusAsync } from '~/modules/auto_launch'
import { getDockVisibilityStatus, loadDockVisibilityAsync } from '~/modules/dock_visibility'
const { t, locale } = useI18n()
const visible = ref(false)
const aboutVisible = ref(false)
const tomlConfig = ref('')
useTray(true)
const items = ref([
{
label: () => activeStep.value == "2" ? t('show_config') : t('edit_config'),
icon: 'pi pi-file-edit',
command: async () => {
try {
const ret = await parseNetworkConfig(networkStore.curNetwork)
tomlConfig.value = ret
}
catch (e: any) {
tomlConfig.value = e
}
visible.value = true
},
},
{
label: () => t('del_cur_network'),
icon: 'pi pi-times',
command: async () => {
networkStore.removeNetworkInstance(networkStore.curNetwork.instance_id)
await retainNetworkInstance(networkStore.networkInstanceIds)
networkStore.delCurNetwork()
},
disabled: () => networkStore.networkList.length <= 1,
},
])
const remoteClient = computed(() => new GUIRemoteClient());
const instanceId = ref<string | undefined>(undefined);
enum Severity {
None = 'none',
Success = 'success',
Info = 'info',
Warn = 'warn',
Error = 'error',
}
const messageBarSeverity = ref(Severity.None)
const messageBarContent = ref('')
const toast = useToast()
const networkStore = useNetworkStore()
const curNetworkConfig = computed(() => {
if (networkStore.curNetworkId) {
// console.log('instanceId', props.instanceId)
const c = networkStore.networkList.find(n => n.instance_id === networkStore.curNetworkId)
if (c !== undefined)
return c
}
return networkStore.curNetwork
})
const curNetworkInst = computed<NetworkTypes.NetworkInstance | null>(() => {
let ret = networkStore.networkInstances.find(n => n.instance_id === curNetworkConfig.value.instance_id)
console.log('curNetworkInst', ret)
if (ret === undefined) {
return null;
} else {
return ret;
}
})
function addNewNetwork() {
networkStore.addNewNetwork()
networkStore.curNetwork = networkStore.lastNetwork
}
networkStore.$subscribe(async () => {
networkStore.saveToLocalStorage()
try {
await parseNetworkConfig(networkStore.curNetwork)
messageBarSeverity.value = Severity.None
}
catch (e: any) {
messageBarContent.value = e
messageBarSeverity.value = Severity.Error
}
})
async function runNetworkCb(cfg: NetworkTypes.NetworkConfig, cb: () => void) {
if (type() === 'android') {
await prepareVpnService(cfg.instance_id)
networkStore.clearNetworkInstances()
}
else {
networkStore.removeNetworkInstance(cfg.instance_id)
}
await retainNetworkInstance(networkStore.networkInstanceIds)
networkStore.addNetworkInstance(cfg.instance_id)
try {
await runNetworkInstance(cfg)
networkStore.addAutoStartInstId(cfg.instance_id)
}
catch (e: any) {
// console.error(e)
toast.add({ severity: 'info', detail: e })
}
cb()
}
async function stopNetworkCb(cfg: NetworkTypes.NetworkConfig, cb: () => void) {
// console.log('stopNetworkCb', cfg, cb)
cb()
networkStore.removeNetworkInstance(cfg.instance_id)
await retainNetworkInstance(networkStore.networkInstanceIds)
networkStore.removeAutoStartInstId(cfg.instance_id)
}
async function updateNetworkInfos() {
networkStore.updateWithNetworkInfos(await collectNetworkInfos())
}
let intervalId = 0
onMounted(async () => {
intervalId = window.setInterval(async () => {
await updateNetworkInfos()
}, 500)
window.setTimeout(async () => {
await setTrayMenu([
await MenuItemShow(t('tray.show')),
@@ -149,16 +28,47 @@ onMounted(async () => {
])
}, 1000)
})
onUnmounted(() => clearInterval(intervalId))
const activeStep = computed(() => {
return networkStore.networkInstanceIds.includes(networkStore.curNetworkId) ? '2' : '1'
})
let current_log_level = 'off'
const setting_menu = ref()
const setting_menu_items = ref([
const log_menu = ref()
const log_menu_items_popup: Ref<MenuItem[]> = ref([
...['off', 'warn', 'info', 'debug', 'trace'].map(level => ({
label: () => t(`logging_level_${level}`) + (current_log_level === level ? ' ✓' : ''),
command: async () => {
current_log_level = level
await setLoggingLevel(level)
},
})),
{
separator: true,
},
{
label: () => t('logging_open_dir'),
icon: 'pi pi-folder-open',
command: async () => {
// console.log('open log dir', await appLogDir())
await open(await appLogDir())
},
},
{
label: () => t('logging_copy_dir'),
icon: 'pi pi-tablet',
command: async () => {
await writeText(await appLogDir())
},
},
])
function toggle_log_menu(event: any) {
log_menu.value.toggle(event)
}
function getLabel(item: MenuItem) {
return typeof item.label === 'function' ? item.label() : item.label
}
const setting_menu_items: Ref<MenuItem[]> = ref([
{
label: () => t('exchange_language'),
icon: 'pi pi-language',
@@ -178,40 +88,18 @@ const setting_menu_items = ref([
},
},
{
label: () => getDockVisibilityStatus() ? t('hide_dock_icon') : t('show_dock_icon'),
icon: 'pi pi-eye-slash',
command: async () => {
await loadDockVisibilityAsync(!getDockVisibilityStatus())
},
visible: () => type() === 'macos',
},
{
key: 'logging_menu',
label: () => t('logging'),
icon: 'pi pi-file',
items: (function () {
const levels = ['off', 'warn', 'info', 'debug', 'trace']
const items = []
for (const level of levels) {
items.push({
label: () => t(`logging_level_${level}`) + (current_log_level === level ? ' ✓' : ''),
command: async () => {
current_log_level = level
await setLoggingLevel(level)
},
})
}
items.push({
separator: true,
})
items.push({
label: () => t('logging_open_dir'),
icon: 'pi pi-folder-open',
command: async () => {
// console.log('open log dir', await appLogDir())
await open(await appLogDir())
},
})
items.push({
label: () => t('logging_copy_dir'),
icon: 'pi pi-tablet',
command: async () => {
await writeText(await appLogDir())
},
})
return items
})(),
items: [], // Keep this to show it's a parent menu
},
{
label: () => t('about.title'),
@@ -229,25 +117,6 @@ const setting_menu_items = ref([
},
])
function toggle_setting_menu(event: any) {
setting_menu.value.toggle(event)
}
onBeforeMount(async () => {
networkStore.loadFromLocalStorage()
if (type() !== 'android' && getAutoLaunchStatus() && await isAutostart()) {
getCurrentWindow().hide()
const autoStartIds = networkStore.autoStartInstIds
for (const id of autoStartIds) {
const cfg = networkStore.networkList.find((item: NetworkTypes.NetworkConfig) => item.instance_id === id)
if (cfg) {
networkStore.addNetworkInstance(cfg.instance_id)
await runNetworkInstance(cfg)
}
}
}
})
onMounted(async () => {
if (type() === 'android') {
try {
@@ -257,123 +126,37 @@ onMounted(async () => {
console.error("easytier init vpn service failed", e)
}
}
const unlisten = await listenGlobalEvents()
await sendConfigs()
return () => {
unlisten()
}
})
function isRunning(id: string) {
return networkStore.networkInstanceIds.includes(id)
}
async function saveTomlConfig(tomlConfig: string) {
const config = await generateNetworkConfig(tomlConfig)
networkStore.replaceCurNetwork(config);
toast.add({ severity: 'success', detail: t('config_saved'), life: 3000 })
visible.value = false
}
</script>
<script lang="ts">
</script>
<template>
<div id="root" class="flex flex-col">
<ConfigEditDialog v-model:visible="visible" :cur-network="curNetworkConfig" :readonly="activeStep !== '1'"
:save-config="saveTomlConfig" :generate-config="parseNetworkConfig" />
<Dialog v-model:visible="aboutVisible" modal :header="t('about.title')" :style="{ width: '70%' }">
<About />
</Dialog>
<Menu ref="log_menu" :model="log_menu_items_popup" :popup="true" />
<div>
<Toolbar>
<template #start>
<div class="flex items-center">
<Button icon="pi pi-plus" severity="primary" :label="t('add_new_network')" @click="addNewNetwork" />
</div>
</template>
<RemoteManagement class="flex-1 overflow-y-auto" :api="remoteClient" v-bind:instance-id="instanceId" />
<template #center>
<div class="min-w-40">
<Select v-model="networkStore.curNetwork" :options="networkStore.networkList" :highlight-on-select="false"
:placeholder="t('select_network')" class="w-full">
<template #value="slotProps">
<div class="flex items-start content-center">
<div class="mr-4 flex-col">
<span>{{ slotProps.value.network_name }}</span>
</div>
<Tag class="my-auto leading-3" :severity="isRunning(slotProps.value.instance_id) ? 'success' : 'info'"
:value="t(isRunning(slotProps.value.instance_id) ? 'network_running' : 'network_stopped')" />
</div>
</template>
<template #option="slotProps">
<div class="flex flex-col items-start content-center max-w-full">
<div class="flex">
<div class="mr-4">
{{ t('network_name') }}: {{ slotProps.option.network_name }}
</div>
<Tag class="my-auto leading-3"
:severity="isRunning(slotProps.option.instance_id) ? 'success' : 'info'"
:value="t(isRunning(slotProps.option.instance_id) ? 'network_running' : 'network_stopped')" />
</div>
<div v-if="slotProps.option.networking_method !== NetworkTypes.NetworkingMethod.Standalone"
class="max-w-full overflow-hidden text-ellipsis">
{{ slotProps.option.networking_method === NetworkTypes.NetworkingMethod.Manual
? slotProps.option.peer_urls.join(', ')
: slotProps.option.public_server_url }}
</div>
<div
v-if="isRunning(slotProps.option.instance_id) && networkStore.instances[slotProps.option.instance_id].detail && (!!networkStore.instances[slotProps.option.instance_id].detail?.my_node_info.virtual_ipv4)">
{{
Utils.ipv4InetToString(networkStore.instances[slotProps.option.instance_id].detail?.my_node_info.virtual_ipv4)
}}
</div>
</div>
</template>
</Select>
</div>
</template>
<template #end>
<Button icon="pi pi-cog" severity="secondary" aria-haspopup="true" :label="t('settings')"
aria-controls="overlay_setting_menu" @click="toggle_setting_menu" />
<TieredMenu id="overlay_setting_menu" ref="setting_menu" :model="setting_menu_items" :popup="true" />
</template>
</Toolbar>
</div>
<Panel class="h-full overflow-y-auto">
<Stepper :value="activeStep">
<StepList value="1">
<Step value="1">
{{ t('config_network') }}
</Step>
<Step value="2">
{{ t('running') }}
</Step>
</StepList>
<StepPanels value="1">
<StepPanel v-slot="{ activateCallback = (s: string) => { } } = {}" value="1">
<Config :instance-id="networkStore.curNetworkId" :config-invalid="messageBarSeverity !== Severity.None"
:cur-network="curNetworkConfig" @run-network="runNetworkCb($event, () => activateCallback('2'))" />
</StepPanel>
<StepPanel v-slot="{ activateCallback = (s: string) => { } } = {}" value="2">
<div class="flex flex-col">
<Status :cur-network-inst="curNetworkInst" />
</div>
<div class="flex pt-6 justify-center">
<Button :label="t('stop_network')" severity="danger" icon="pi pi-arrow-left"
@click="stopNetworkCb(networkStore.curNetwork, () => activateCallback('1'))" />
</div>
</StepPanel>
</StepPanels>
</Stepper>
</Panel>
<div>
<Menubar :model="items" breakpoint="300px" />
<InlineMessage v-if="messageBarSeverity !== Severity.None" class="absolute bottom-0 right-0" severity="error">
{{ messageBarContent }}
</InlineMessage>
</div>
<Menubar :model="setting_menu_items" breakpoint="560px">
<template #item="{ item, props }">
<a v-if="item.key === 'logging_menu'" v-bind="props.action" @click="toggle_log_menu">
<span :class="item.icon" />
<span class="p-menubar-item-label">{{ getLabel(item) }}</span>
<span class="pi pi-angle-down p-menubar-item-icon text-[9px]"></span>
</a>
<a v-else v-bind="props.action">
<span :class="item.icon" />
<span class="p-menubar-item-label">{{ getLabel(item) }}</span>
</a>
</template>
</Menubar>
</div>
</template>

View File

@@ -1,148 +0,0 @@
import { NetworkTypes } from 'easytier-frontend-lib'
export const useNetworkStore = defineStore('networkStore', {
state: () => {
const networkList = [NetworkTypes.DEFAULT_NETWORK_CONFIG()]
return {
// for initially empty lists
networkList: networkList as NetworkTypes.NetworkConfig[],
// for data that is not yet loaded
curNetwork: networkList[0],
// uuid -> instance
instances: {} as Record<string, NetworkTypes.NetworkInstance>,
networkInfos: {} as Record<string, NetworkTypes.NetworkInstanceRunningInfo>,
autoStartInstIds: [] as string[],
}
},
getters: {
lastNetwork(): NetworkTypes.NetworkConfig {
return this.networkList[this.networkList.length - 1]
},
curNetworkId(): string {
return this.curNetwork.instance_id
},
networkInstances(): Array<NetworkTypes.NetworkInstance> {
return Object.values(this.instances)
},
networkInstanceIds(): Array<string> {
return Object.keys(this.instances)
},
},
actions: {
addNewNetwork() {
this.networkList.push(NetworkTypes.DEFAULT_NETWORK_CONFIG())
},
delCurNetwork() {
const curNetworkIdx = this.networkList.indexOf(this.curNetwork)
this.networkList.splice(curNetworkIdx, 1)
const nextCurNetworkIdx = Math.min(curNetworkIdx, this.networkList.length - 1)
this.curNetwork = this.networkList[nextCurNetworkIdx]
},
replaceCurNetwork(cfg: NetworkTypes.NetworkConfig) {
const curNetworkIdx = this.networkList.indexOf(this.curNetwork)
this.networkList[curNetworkIdx] = cfg
this.curNetwork = cfg
},
removeNetworkInstance(instanceId: string) {
delete this.instances[instanceId]
},
addNetworkInstance(instanceId: string) {
this.instances[instanceId] = {
instance_id: instanceId,
running: false,
error_msg: '',
detail: undefined,
}
},
clearNetworkInstances() {
this.instances = {}
},
updateWithNetworkInfos(networkInfos: Record<string, NetworkTypes.NetworkInstanceRunningInfo>) {
this.networkInfos = networkInfos
for (const [instanceId, info] of Object.entries(networkInfos)) {
if (this.instances[instanceId] === undefined)
this.addNetworkInstance(instanceId)
this.instances[instanceId].running = info.running
this.instances[instanceId].error_msg = info.error_msg || ''
this.instances[instanceId].detail = info
}
},
loadFromLocalStorage() {
let networkList: NetworkTypes.NetworkConfig[]
// if localStorage default is [{}], instanceId will be undefined
networkList = JSON.parse(localStorage.getItem('networkList') || '[]')
networkList = networkList.map((cfg) => {
return { ...NetworkTypes.DEFAULT_NETWORK_CONFIG(), ...cfg } as NetworkTypes.NetworkConfig
})
// prevent a empty list from localStorage, should not happen
if (networkList.length === 0)
networkList = [NetworkTypes.DEFAULT_NETWORK_CONFIG()]
this.networkList = networkList
this.curNetwork = this.networkList[0]
this.loadAutoStartInstIdsFromLocalStorage()
},
saveToLocalStorage() {
localStorage.setItem('networkList', JSON.stringify(this.networkList))
},
saveAutoStartInstIdsToLocalStorage() {
localStorage.setItem('autoStartInstIds', JSON.stringify(this.autoStartInstIds))
},
loadAutoStartInstIdsFromLocalStorage() {
try {
this.autoStartInstIds = JSON.parse(localStorage.getItem('autoStartInstIds') || '[]')
}
catch (e) {
console.error(e)
this.autoStartInstIds = []
}
},
addAutoStartInstId(instanceId: string) {
if (!this.autoStartInstIds.includes(instanceId)) {
this.autoStartInstIds.push(instanceId)
}
this.saveAutoStartInstIdsToLocalStorage()
},
removeAutoStartInstId(instanceId: string) {
const idx = this.autoStartInstIds.indexOf(instanceId)
if (idx !== -1) {
this.autoStartInstIds.splice(idx, 1)
}
this.saveAutoStartInstIdsToLocalStorage()
},
isNoTunEnabled(instanceId: string): boolean {
const cfg = this.networkList.find((cfg) => cfg.instance_id === instanceId)
if (!cfg)
return false
return cfg.no_tun ?? false
},
},
})
if (import.meta.hot)
import.meta.hot.accept(acceptHMRUpdate(useNetworkStore as any, import.meta.hot))

View File

@@ -1,6 +1,6 @@
[package]
name = "easytier-web"
version = "2.4.3"
version = "2.4.5"
edition = "2021"
description = "Config server for easytier. easytier-core gets config from this and web frontend use it as restful api server."
@@ -64,6 +64,8 @@ uuid = { version = "1.5.0", features = [
chrono = { version = "0.4.37", features = ["serde"] }
mimalloc = { version = "*" }
[features]
default = []
embed = ["dep:axum-embed"]

View File

@@ -18,18 +18,17 @@
"preview": "vite preview"
},
"dependencies": {
"@primevue/themes": "4.3.3",
"@primeuix/themes": "^1.2.3",
"@vueuse/core": "^11.1.0",
"aura": "link:@primevue\\themes\\aura",
"axios": "^1.7.7",
"chart.js": "^4.5.0",
"floating-vue": "^5.2",
"ip-num": "1.5.1",
"primeicons": "^7.0.0",
"primevue": "4.3.3",
"tailwindcss-primeui": "^0.3.4",
"ts-md5": "^1.3.1",
"uuid": "^11.0.2",
"vue": "^3.5.12",
"vue-chartjs": "^5.3.2",
"vue-i18n": "^10.0.4"
},
"devDependencies": {
@@ -45,5 +44,9 @@
"vite": "^5.4.10",
"vite-plugin-dts": "^4.3.0",
"vue-tsc": "^2.1.10"
},
"peerDependencies": {
"vue": "^3.5.12",
"primevue": "^4.3.9"
}
}

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import InputGroup from 'primevue/inputgroup'
import InputGroupAddon from 'primevue/inputgroupaddon'
import { SelectButton, Checkbox, InputText, InputNumber, AutoComplete, Panel, Divider, ToggleButton, Button, Password } from 'primevue'
import { SelectButton, Checkbox, InputText, InputNumber, AutoComplete, Panel, Divider, ToggleButton, Button, Password, Dialog } from 'primevue'
import {
addRow,
DEFAULT_NETWORK_CONFIG,
@@ -9,7 +9,7 @@ import {
NetworkingMethod,
removeRow
} from '../types/network'
import { defineProps, defineEmits, ref, } from 'vue'
import { defineProps, defineEmits, ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps<{
@@ -157,6 +157,7 @@ const bool_flags: BoolFlag[] = [
{ field: 'enable_quic_proxy', help: 'enable_quic_proxy_help' },
{ field: 'disable_quic_input', help: 'disable_quic_input_help' },
{ field: 'disable_p2p', help: 'disable_p2p_help' },
{ field: 'p2p_only', help: 'p2p_only_help' },
{ field: 'bind_device', help: 'bind_device_help' },
{ field: 'no_tun', help: 'no_tun_help' },
{ field: 'enable_exit_node', help: 'enable_exit_node_help' },
@@ -170,15 +171,58 @@ const bool_flags: BoolFlag[] = [
{ field: 'enable_private_mode', help: 'enable_private_mode_help' },
]
const portForwardProtocolOptions = ref(["tcp","udp"]);
const portForwardProtocolOptions = ref(["tcp", "udp"]);
const editingPortForward = ref(false);
const editingPortForwardIndex = ref(-1);
const editingPortForwardData = ref();
function openPortForwardEditor(index: number) {
editingPortForwardIndex.value = index;
// deep copy
editingPortForwardData.value = JSON.parse(JSON.stringify(curNetwork.value.port_forwards[index]));
editingPortForward.value = true;
}
function addPortForward() {
addRow(curNetwork.value.port_forwards)
if (isCompact.value) {
openPortForwardEditor(curNetwork.value.port_forwards.length - 1)
}
}
function savePortForward() {
curNetwork.value.port_forwards[editingPortForwardIndex.value] = editingPortForwardData.value;
editingPortForward.value = false;
}
const portForwardContainer = ref<HTMLElement | null>(null);
const isCompact = ref(false);
onMounted(() => {
if (portForwardContainer.value) {
let resizeObserver = new ResizeObserver(entries => {
for (const entry of entries) {
isCompact.value = entry.contentRect.width < 540;
}
});
resizeObserver.observe(portForwardContainer.value);
return () => {
if (resizeObserver && portForwardContainer.value) {
resizeObserver.unobserve(portForwardContainer.value);
}
}
}
});
</script>
<template>
<div class="frontend-lib">
<div class="flex flex-col h-full">
<div class="flex flex-col">
<div class="w-11/12 self-center ">
<div class="w-full self-center ">
<Panel :header="t('basic_settings')">
<div class="flex flex-col gap-y-2">
<div class="flex flex-row gap-x-9 flex-wrap">
@@ -227,9 +271,8 @@ const portForwardProtocolOptions = ref(["tcp","udp"]);
class="grow" multiple fluid :suggestions="peerSuggestions" @complete="searchPeerSuggestions" />
<AutoComplete v-if="curNetwork.networking_method === NetworkingMethod.PublicServer"
v-model="curNetwork.public_server_url" :suggestions="publicServerSuggestions"
class="grow" dropdown :complete-on-focus="false"
@complete="searchPresetPublicServers" />
v-model="curNetwork.public_server_url" :suggestions="publicServerSuggestions" class="grow"
dropdown :complete-on-focus="false" @complete="searchPresetPublicServers" />
</div>
</div>
</div>
@@ -308,23 +351,6 @@ const portForwardProtocolOptions = ref(["tcp","udp"]);
</div>
</div>
<div class="flex flex-row gap-x-9 flex-wrap">
<div class="flex flex-col gap-2 basis-5/12 grow">
<label for="rpc_port">{{ t('rpc_port') }}</label>
<InputNumber id="rpc_port" v-model="curNetwork.rpc_port" aria-describedby="rpc_port-help"
:format="false" :min="0" :max="65535" />
</div>
</div>
<div class="flex flex-row gap-x-9 flex-wrap w-full">
<div class="flex flex-col gap-2 grow p-fluid">
<label for="">{{ t('rpc_portal_whitelists') }}</label>
<AutoComplete id="rpc_portal_whitelists" v-model="curNetwork.rpc_portal_whitelists"
:placeholder="t('chips_placeholder', ['127.0.0.0/8'])" class="w-full" multiple fluid
:suggestions="inetSuggestions" @complete="searchInetSuggestions" />
</div>
</div>
<div class="flex flex-row gap-x-9 flex-wrap">
<div class="flex flex-col gap-2 basis-5/12 grow">
<label for="dev_name">{{ t('dev_name') }}</label>
@@ -428,65 +454,87 @@ const portForwardProtocolOptions = ref(["tcp","udp"]);
<Divider />
<Panel :header="t('port_forwards')" toggleable collapsed>
<div class="flex flex-col gap-y-2">
<div ref="portForwardContainer" class="flex flex-col gap-y-2">
<div class="flex flex-row gap-x-9 flex-wrap w-full">
<div class="flex flex-col gap-2 grow p-fluid">
<div class="flex">
<label for="port_forwards">{{ t('port_forwards_help') }}</label>
</div>
<div v-for="(row, index) in curNetwork.port_forwards" class="form-row">
<div style="display: flex; gap: 0.5rem; align-items: flex-end;">
<SelectButton v-model="row.proto" :options="portForwardProtocolOptions" :allow-empty="false"/>
<div v-for="(row, index) in curNetwork.port_forwards" :key="index" class="form-row">
<!-- Wide screen view -->
<div v-if="!isCompact" class="flex gap-2 items-end">
<SelectButton v-model="row.proto" :options="portForwardProtocolOptions" :allow-empty="false" />
<div style="flex-grow: 4;">
<InputGroup>
<InputText
v-model="row.bind_ip"
:placeholder="t('port_forwards_bind_addr')"
/>
<InputText v-model="row.bind_ip" :placeholder="t('port_forwards_bind_addr')" />
<InputGroupAddon>
<span style="font-weight: bold">:</span>
</InputGroupAddon>
<InputNumber v-model="row.bind_port" :format="false"
inputId="horizontal-buttons" :step="1" mode="decimal" :min="1"
:max="65535" fluid
class="max-w-20"/>
<InputNumber v-model="row.bind_port" :format="false" inputId="horizontal-buttons" :step="1"
mode="decimal" :min="1" :max="65535" fluid class="max-w-20" />
</InputGroup>
</div>
<div style="flex-grow: 4;">
<InputGroup>
<InputText
v-model="row.dst_ip"
:placeholder="t('port_forwards_dst_addr')"
/>
<InputText v-model="row.dst_ip" :placeholder="t('port_forwards_dst_addr')" />
<InputGroupAddon>
<span style="font-weight: bold">:</span>
</InputGroupAddon>
<InputNumber v-model="row.dst_port" :format="false"
inputId="horizontal-buttons" :step="1" mode="decimal" :min="1"
:max="65535" fluid
class="max-w-20"/>
<InputNumber v-model="row.dst_port" :format="false" inputId="horizontal-buttons" :step="1"
mode="decimal" :min="1" :max="65535" fluid class="max-w-20" />
</InputGroup>
</div>
<div style="flex-grow: 1;">
<Button
v-if="curNetwork.port_forwards.length > 0"
icon="pi pi-trash"
severity="danger"
text
rounded
@click="removeRow(index,curNetwork.port_forwards)"
/>
<Button v-if="curNetwork.port_forwards.length > 0" icon="pi pi-trash" severity="danger" text
rounded @click="removeRow(index, curNetwork.port_forwards)" />
</div>
</div>
<!-- Small screen view -->
<div v-else class="flex justify-between items-center p-2 border-b">
<span>{{ row.proto }}://{{ row.bind_ip }}:{{ row.bind_port }}/{{ row.dst_ip }}:{{
row.dst_port }}</span>
<div class="flex gap-2">
<Button icon="pi pi-pencil" class="p-button-sm" @click="openPortForwardEditor(index)" />
<Button icon="pi pi-trash" class="p-button-sm p-button-danger"
@click="removeRow(index, curNetwork.port_forwards)" />
</div>
</div>
</div>
<div class="flex justify-content-end mt-4">
<Button
icon="pi pi-plus"
:label="t('port_forwards_add_btn')"
severity="success"
@click="addRow(curNetwork.port_forwards)"
/>
<Button icon="pi pi-plus" :label="t('port_forwards_add_btn')" severity="success"
@click="addPortForward" />
</div>
<Dialog v-model:visible="editingPortForward" modal :header="t('edit_port_forward')"
:style="{ width: '90vw', maxWidth: '600px' }">
<div v-if="editingPortForwardData" class="flex flex-col gap-4">
<SelectButton v-model="editingPortForwardData.proto" :options="portForwardProtocolOptions"
:allow-empty="false" />
<InputGroup>
<InputText v-model="editingPortForwardData.bind_ip"
:placeholder="t('port_forwards_bind_addr')" />
<InputGroupAddon>
<span style="font-weight: bold">:</span>
</InputGroupAddon>
<InputNumber v-model="editingPortForwardData.bind_port" :format="false" :step="1" mode="decimal"
:min="1" :max="65535" class="max-w-20" />
</InputGroup>
<InputGroup>
<InputText v-model="editingPortForwardData.dst_ip" :placeholder="t('port_forwards_dst_addr')" />
<InputGroupAddon>
<span style="font-weight: bold">:</span>
</InputGroupAddon>
<InputNumber v-model="editingPortForwardData.dst_port" :format="false" :step="1" mode="decimal"
:min="1" :max="65535" class="max-w-20" />
</InputGroup>
</div>
<template #footer>
<Button :label="t('web.common.cancel')" icon="pi pi-times" @click="editingPortForward = false"
text />
<Button :label="t('web.common.save')" icon="pi pi-save" @click="savePortForward" />
</template>
</Dialog>
</div>
</div>
</div>

View File

@@ -0,0 +1,279 @@
<template>
<div
class="bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-blue-900/20 dark:to-indigo-800/20 rounded-xl p-4 border border-blue-200 dark:border-blue-700 shadow-md hover:shadow-lg transition-all duration-300">
<div class="flex items-center justify-center mb-3">
<div class="flex gap-2 text-sm">
<span class="flex items-center gap-1 w-32">
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
<span class="text-green-600 dark:text-green-400 truncate">{{ t('upload') }}: {{ currentUpload }}/s</span>
</span>
<span class="flex items-center gap-1 w-32">
<div class="w-2 h-2 bg-blue-500 rounded-full"></div>
<span class="text-blue-600 dark:text-blue-400 truncate">{{ t('download') }}: {{ currentDownload }}/s</span>
</span>
</div>
</div>
<div class="h-32">
<canvas ref="chartCanvas"></canvas>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
LineController,
Title,
Tooltip,
Legend,
Filler
} from 'chart.js'
import { useI18n } from 'vue-i18n';
const { t } = useI18n()
// 注册Chart.js组件
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
LineController,
Title,
Tooltip,
Legend,
Filler
)
interface Props {
uploadRate: string
downloadRate: string
}
const props = defineProps<Props>()
const chartCanvas = ref<HTMLCanvasElement>()
let chart: ChartJS | null = null
let updateTimer: number | null = null
// 存储历史数据最多保存30个数据点1分钟历史
const maxDataPoints = 120
const uploadHistory: number[] = []
const downloadHistory: number[] = []
const timeLabels: string[] = []
const currentUpload = ref('0')
const currentDownload = ref('0')
// 将带单位的速率字符串转换为字节数
function parseRateToBytes(rateStr: string): number {
if (!rateStr || rateStr === '0') return 0
const match = rateStr.match(/([0-9.]+)\s*([KMGT]?i?B)/i)
if (!match) return 0
const value = parseFloat(match[1])
const unit = match[2].toUpperCase()
const multipliers: { [key: string]: number } = {
'B': 1,
'KB': 1000,
'KIB': 1024,
'MB': 1000000,
'MIB': 1024 * 1024,
'GB': 1000000000,
'GIB': 1024 * 1024 * 1024,
'TB': 1000000000000,
'TIB': 1024 * 1024 * 1024 * 1024
}
return value * (multipliers[unit] || 1)
}
// 格式化字节为可读格式
function formatBytes(bytes: number): string {
if (bytes < 1) return bytes.toFixed(1) + ' B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
// 更新数据
function updateData() {
const uploadBytes = parseRateToBytes(props.uploadRate)
const downloadBytes = parseRateToBytes(props.downloadRate)
currentUpload.value = formatBytes(uploadBytes)
currentDownload.value = formatBytes(downloadBytes)
// 添加新数据点
uploadHistory.push(uploadBytes)
downloadHistory.push(downloadBytes)
// 生成时间标签
const now = new Date()
const timeStr = now.toLocaleTimeString('zh-CN', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
timeLabels.push(timeStr)
// 保持数据点数量不超过最大值
if (uploadHistory.length > maxDataPoints) {
uploadHistory.shift()
downloadHistory.shift()
timeLabels.shift()
}
// 更新图表
if (chart) {
chart.data.labels = timeLabels
chart.data.datasets[0].data = uploadHistory
chart.data.datasets[1].data = downloadHistory
chart.update('none')
}
}
// 初始化图表
function initChart() {
if (!chartCanvas.value) return
const ctx = chartCanvas.value.getContext('2d')
if (!ctx) return
chart = new ChartJS(ctx, {
type: 'line',
data: {
labels: timeLabels,
datasets: [
{
label: t('upload'),
data: uploadHistory,
borderColor: 'rgb(34, 197, 94)',
backgroundColor: 'rgba(34, 197, 94, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4
},
{
label: t('download'),
data: downloadHistory,
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index'
},
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
label: function (context: any) {
const value = context.parsed.y
return `${context.dataset.label}: ${formatBytes(value)}/s`
}
}
}
},
scales: {
x: {
display: true,
grid: {
display: false
},
ticks: {
maxTicksLimit: 3,
font: {
size: 8
}
}
},
y: {
display: true,
beginAtZero: true,
min: 0,
grid: {
color: 'rgba(0, 0, 0, 0.1)'
},
ticks: {
callback: function (value: any) {
return formatBytes(value as number)
},
font: {
size: 8
},
},
}
},
animation: {
duration: 10
}
}
})
}
// 监听props变化
watch([() => props.uploadRate, () => props.downloadRate], () => {
updateData()
}, { immediate: true })
onMounted(async () => {
// add initial point
const now = new Date();
for (let i = 0; i < maxDataPoints; i++) {
let date = new Date(now.getTime() - (maxDataPoints - i) * 2000)
const timeStr = date.toLocaleTimeString(navigator.language, {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
uploadHistory.push(0)
downloadHistory.push(0)
timeLabels.push(timeStr)
}
await nextTick()
initChart()
updateData()
// 启动定时器每2秒更新一次图表
updateTimer = window.setInterval(() => {
updateData()
}, 2000)
})
onUnmounted(() => {
if (chart) {
chart.destroy()
}
if (updateTimer) {
clearInterval(updateTimer)
}
})
</script>

View File

@@ -0,0 +1,700 @@
<script setup lang="ts">
import { Button, ConfirmPopup, Divider, IftaLabel, Menu, Message, Select, Tag, useConfirm, useToast, type VirtualScrollerLazyEvent } from 'primevue';
import { computed, onMounted, onUnmounted, Ref, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import * as Api from '../modules/api';
import * as Utils from '../modules/utils';
import * as NetworkTypes from '../types/network';
import { type MenuItem } from 'primevue/menuitem';
const { t } = useI18n()
const props = defineProps<{
api: Api.RemoteClient;
newConfigGenerator?: () => NetworkTypes.NetworkConfig;
}>();
const instanceId = defineModel('instanceId', {
type: String as () => string | undefined,
required: false,
})
const emits = defineEmits(['update']);
const toast = useToast();
const configFile = ref();
const curNetworkInfo = ref<NetworkTypes.NetworkInstance | null>(null);
const showConfigEditDialog = ref(false);
const isEditingNetwork = ref(false); // Flag to indicate if we're in network editing mode
const currentNetworkConfig = ref<NetworkTypes.NetworkConfig | undefined>(undefined);
const listInstanceIdResponse = ref<Api.ListNetworkInstanceIdResponse | undefined>(undefined);
const isRunning = (instanceId: string) => {
return listInstanceIdResponse.value?.running_inst_ids.map(Utils.UuidToStr).includes(instanceId);
}
const networkMetaCache = ref<Record<string, Api.NetworkMeta>>({});
const loadNetworkMetas = async (instanceIds: string[]) => {
const missingIds = instanceIds.filter(id => !networkMetaCache.value[id]);
if (missingIds.length === 0) return;
try {
const response = await props.api.get_network_metas(missingIds);
Object.assign(networkMetaCache.value, response.metas);
} catch (e) {
console.error("Failed to load network metas", e);
}
};
const onLazyLoadNetworkMetas = async (event: VirtualScrollerLazyEvent) => {
const instanceIds = instanceList.value
.slice(event.first, event.last + 1)
.map(item => item.uuid);
await loadNetworkMetas(instanceIds);
};
const currentNetworkMeta = computed(() => {
if (!instanceId.value) {
return undefined;
}
return networkMetaCache.value[instanceId.value];
});
const currentNetworkControl = {
remoteSave: computed(() => {
return Api.ConfigFilePermission.isRemoveSaveable(currentNetworkMeta.value?.config_permission ?? 0);
}),
editable: computed(() => {
return Api.ConfigFilePermission.isEditable(currentNetworkMeta.value?.config_permission ?? 0);
}),
deletable: computed(() => {
return Api.ConfigFilePermission.isDeletable(currentNetworkMeta.value?.config_permission ?? 0);
})
}
const instanceList = ref<Array<{ uuid: string; meta?: Api.NetworkMeta }>>([]);
const updateInstanceList = () => {
let insts = new Set<string>();
let t = listInstanceIdResponse.value;
if (t) {
t.running_inst_ids.forEach((u) => insts.add(Utils.UuidToStr(u)));
t.disabled_inst_ids.forEach((u) => insts.add(Utils.UuidToStr(u)));
}
const newList = Array.from(insts).map((instance: string) => {
return {
uuid: instance,
meta: networkMetaCache.value[instance]
};
});
if (JSON.stringify(newList) !== JSON.stringify(instanceList.value)) {
instanceList.value = newList;
}
}
watch(listInstanceIdResponse, updateInstanceList, { deep: false });
watch(networkMetaCache, updateInstanceList, { deep: true });
watch(instanceList, async (newVal) => {
if (newVal) {
const instanceIds = new Set(newVal.map(item => item.uuid));
Object.keys(networkMetaCache.value).forEach(id => {
if (!instanceIds.has(id)) {
delete networkMetaCache.value[id];
}
});
}
});
const selectedInstanceId = computed({
get() {
return instanceList.value.find((instance) => instance.uuid === instanceId.value);
},
set(value: any) {
console.log("set instanceId", value);
instanceId.value = value ? value.uuid : undefined;
}
});
watch(selectedInstanceId, async (newVal, oldVal) => {
if (newVal?.uuid !== oldVal?.uuid && (networkIsDisabled.value || isEditingNetwork.value)) {
await loadCurrentNetworkConfig();
} else {
await loadCurrentNetworkInfo();
}
if (newVal?.uuid && !networkMetaCache.value[newVal.uuid]) {
await loadNetworkMetas([newVal.uuid]);
}
});
const needShowNetworkStatus = computed(() => {
if (!selectedInstanceId.value) {
// nothing selected
return false;
}
if (networkIsDisabled.value) {
// network is disabled
return false;
}
if (isEditingNetwork.value) {
// editing network
return false;
}
return true;
})
const networkIsDisabled = computed(() => {
if (!selectedInstanceId.value) {
return false;
}
return listInstanceIdResponse.value?.disabled_inst_ids.map(Utils.UuidToStr).includes(selectedInstanceId.value?.uuid);
});
watch(networkIsDisabled, async (newVal, oldVal) => {
if (newVal !== oldVal && newVal === true) {
await loadCurrentNetworkConfig();
}
});
const loadCurrentNetworkConfig = async () => {
currentNetworkConfig.value = undefined;
if (!selectedInstanceId.value) {
return;
}
let ret = await props.api.get_network_config(selectedInstanceId.value!.uuid);
currentNetworkConfig.value = ret;
}
const stopNetwork = async () => {
if (!selectedInstanceId.value) {
return;
}
await props.api.update_network_instance_state(selectedInstanceId.value.uuid, true);
await loadNetworkInstanceIds();
}
const confirm = useConfirm();
const confirmDeleteNetwork = (event: any) => {
confirm.require({
target: event.currentTarget,
message: 'Do you want to delete this network?',
icon: 'pi pi-info-circle',
rejectProps: {
label: 'Cancel',
severity: 'secondary',
outlined: true
},
acceptProps: {
label: 'Delete',
severity: 'danger'
},
accept: async () => {
try {
await props.api.delete_network(instanceId.value!);
} catch (e) {
console.error(e);
}
emits('update');
},
reject: () => {
return;
}
});
};
const saveAndRunNewNetwork = async () => {
if (!currentNetworkConfig.value) {
return;
}
try {
await props.api.delete_network(instanceId.value!);
let ret = await props.api.run_network(currentNetworkConfig.value, currentNetworkControl.remoteSave.value);
console.debug("saveAndRunNewNetwork", ret);
delete networkMetaCache.value[currentNetworkConfig.value.instance_id];
await loadNetworkMetas([currentNetworkConfig.value.instance_id]);
selectedInstanceId.value = { uuid: currentNetworkConfig.value.instance_id };
} catch (e: any) {
console.error(e);
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to create network, error: ' + JSON.stringify(e.response.data), life: 2000 });
return;
}
emits('update');
// showCreateNetworkDialog.value = false;
isEditingNetwork.value = false; // Exit creation mode after successful network creation
}
const saveNetworkConfig = async () => {
if (!currentNetworkConfig.value) {
return;
}
await props.api.save_config(currentNetworkConfig.value);
delete networkMetaCache.value[currentNetworkConfig.value.instance_id];
await loadNetworkMetas([currentNetworkConfig.value.instance_id]);
toast.add({ severity: 'success', summary: t("web.common.success"), detail: t("web.device_management.config_saved"), life: 2000 });
}
const newNetwork = async () => {
const newNetworkConfig = props.newConfigGenerator?.() ?? NetworkTypes.DEFAULT_NETWORK_CONFIG();
await props.api.save_config(newNetworkConfig);
selectedInstanceId.value = { uuid: newNetworkConfig.instance_id };
currentNetworkConfig.value = newNetworkConfig;
await loadNetworkInstanceIds();
}
const cancelEditNetwork = () => {
isEditingNetwork.value = false;
}
const editNetwork = async () => {
if (!instanceId.value) {
toast.add({ severity: 'error', summary: 'Error', detail: 'No network instance selected', life: 2000 });
return;
}
try {
let ret = await props.api.get_network_config(instanceId.value!);
console.debug("editNetwork", ret);
currentNetworkConfig.value = ret;
isEditingNetwork.value = true; // Switch to editing mode instead
} catch (e: any) {
console.error(e);
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to edit network, error: ' + JSON.stringify(e.response.data), life: 2000 });
return;
}
}
const loadNetworkInstanceIds = async () => {
listInstanceIdResponse.value = await props.api.list_network_instance_ids();
}
const loadCurrentNetworkInfo = async () => {
if (!selectedInstanceId.value) {
return;
}
if (!needShowNetworkStatus.value) {
return;
}
let network_info = await props.api.get_network_info(selectedInstanceId.value.uuid);
curNetworkInfo.value = {
instance_id: selectedInstanceId.value.uuid,
running: network_info?.running ?? false,
error_msg: network_info?.error_msg ?? '',
detail: network_info,
} as NetworkTypes.NetworkInstance;
}
const exportConfig = async () => {
if (!instanceId.value) {
toast.add({ severity: 'error', summary: 'Error', detail: 'No network instance selected', life: 2000 });
return;
}
try {
const { instance_id, ...networkConfig } = await props.api.get_network_config(instanceId.value!);
let { toml_config: tomlConfig, error } = await props.api.generate_config(networkConfig as NetworkTypes.NetworkConfig);
if (error) {
throw { response: { data: error } };
}
exportTomlFile(tomlConfig ?? '', instanceId.value + '.toml');
} catch (e: any) {
console.error(e);
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to export network config, error: ' + JSON.stringify(e.response.data), life: 2000 });
return;
}
}
const importConfig = () => {
configFile.value.click();
}
const handleFileUpload = (event: Event) => {
const files = (event.target as HTMLInputElement).files;
const file = files ? files[0] : null;
if (!file) return;
const reader = new FileReader();
reader.onload = async (e) => {
try {
let tomlConfig = e.target?.result?.toString();
if (!tomlConfig) return;
const resp = await props.api.parse_config(tomlConfig);
if (resp.error) {
throw resp.error;
}
const config = resp.config;
if (!config) return;
config.instance_id = currentNetworkConfig.value?.instance_id ?? config?.instance_id;
currentNetworkConfig.value = config;
toast.add({ severity: 'success', summary: 'Import Success', detail: "Config file import success", life: 2000 });
} catch (error) {
toast.add({ severity: 'error', summary: 'Error', detail: 'Config file parse error: ' + error, life: 2000 });
}
configFile.value.value = null;
}
reader.readAsText(file);
}
const exportTomlFile = (context: string, name: string) => {
let url = window.URL.createObjectURL(new Blob([context], { type: 'application/toml' }));
let link = document.createElement('a');
link.style.display = 'none';
link.href = url;
link.setAttribute('download', name);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
}
const generateConfig = async (config: NetworkTypes.NetworkConfig): Promise<string> => {
let { toml_config: tomlConfig, error } = await props.api.generate_config(config);
if (error) {
throw error;
}
return tomlConfig ?? '';
}
const syncTomlConfig = async (tomlConfig: string): Promise<void> => {
let resp = await props.api.parse_config(tomlConfig);
if (resp.error) {
throw resp.error;
};
const config = resp.config;
if (!config) {
throw new Error("Parsed config is empty");
}
config.instance_id = currentNetworkConfig.value?.instance_id ?? config?.instance_id;
currentNetworkConfig.value = config;
}
// 响应式屏幕宽度
const screenWidth = ref(window.innerWidth);
const updateScreenWidth = () => {
screenWidth.value = window.innerWidth;
};
// 菜单引用和菜单项
const menuRef = ref();
const actionMenu: Ref<MenuItem[]> = ref([
{
label: t('web.device_management.edit_network'),
icon: 'pi pi-pencil',
visible: () => !(networkIsDisabled.value ?? true) && currentNetworkControl.editable.value,
command: () => editNetwork()
},
{
label: t('web.device_management.export_config'),
icon: 'pi pi-download',
command: () => exportConfig()
},
{
label: t('web.device_management.delete_network'),
icon: 'pi pi-trash',
class: 'p-error',
visible: () => currentNetworkControl.deletable.value,
command: () => confirmDeleteNetwork(new Event('click'))
}
]);
let periodFunc = new Utils.PeriodicTask(async () => {
try {
await Promise.all([loadNetworkInstanceIds(), loadCurrentNetworkInfo()]);
} catch (e) {
console.debug(e);
}
}, 1000);
onMounted(async () => {
periodFunc.start();
// 添加屏幕尺寸监听
window.addEventListener('resize', updateScreenWidth);
});
onUnmounted(() => {
periodFunc.stop();
// 移除屏幕尺寸监听
window.removeEventListener('resize', updateScreenWidth);
});
</script>
<template>
<div class="device-management">
<input type="file" @change="handleFileUpload" class="hidden" accept="application/toml" ref="configFile" />
<ConfirmPopup></ConfirmPopup>
<!-- 网络选择和操作按钮始终在同一行 -->
<div class="network-header bg-surface-50 p-3 rounded-lg shadow-sm mb-1">
<div class="flex flex-row justify-between items-center gap-2" style="align-items: center;">
<!-- 网络选择 -->
<div class="flex-1 min-w-0">
<IftaLabel class="w-full">
<Select v-model="selectedInstanceId" :options="instanceList" optionLabel="uuid" class="w-full"
inputId="dd-inst-id" :placeholder="t('web.device_management.select_network')"
:pt="{ root: { class: 'network-select-container' } }" :virtualScrollerOptions="{
lazy: true,
onLazyLoad: onLazyLoadNetworkMetas,
itemSize: 60,
delay: 50
}">
<template #value="slotProps">
<div v-if="slotProps.value" class="flex items-center content-center min-w-0">
<div class="mr-4 flex-col min-w-0 flex-1">
<span class="truncate block">
&nbsp;
<span v-if="slotProps.value.meta">
{{ slotProps.value.meta.network_name }} ({{ slotProps.value.uuid }})
</span>
<span v-else>
{{ slotProps.value.uuid }}
</span>
</span>
</div>
<Tag class="my-auto leading-3 shrink-0"
:severity="isRunning(slotProps.value.uuid) ? 'success' : 'info'"
:value="t(isRunning(slotProps.value.uuid) ? 'network_running' : 'network_stopped')" />
</div>
<span v-else>
{{ slotProps.placeholder }}
</span>
</template>
<template #option="slotProps">
<div class="flex flex-col items-start content-center max-w-full">
<div class="flex items-center min-w-0">
<div class="mr-4 min-w-0 flex-1">
<span class="truncate block">{{ t('network_name') }}: {{
slotProps.option.meta.network_name }}</span>
</div>
<Tag class="my-auto leading-3 shrink-0"
:severity="isRunning(slotProps.option.uuid) ? 'success' : 'info'"
:value="t(isRunning(slotProps.option.uuid) ? 'network_running' : 'network_stopped')" />
</div>
<div class="max-w-full overflow-hidden text-ellipsis text-gray-500">
{{ slotProps.option.uuid }}
</div>
</div>
</template>
</Select>
<label class="network-label mr-2 font-medium" for="dd-inst-id">{{
t('web.device_management.network') }}</label>
</IftaLabel>
</div>
<!-- 简化的按钮区域 - 无论屏幕大小都显示 -->
<div class="flex gap-2 shrink-0 button-container items-center">
<!-- Create/Cancel button based on state -->
<Button v-if="!isEditingNetwork" @click="newNetwork" icon="pi pi-plus"
:label="screenWidth > 640 ? t('web.device_management.create_new') : undefined"
:class="['create-button', screenWidth <= 640 ? 'p-button-icon-only' : '']"
:style="screenWidth <= 640 ? 'width: 3rem !important; height: 3rem !important; font-size: 1.2rem' : ''"
:tooltip="screenWidth <= 640 ? t('web.device_management.create_network') : undefined"
tooltipOptions="{ position: 'bottom' }" severity="primary" />
<Button v-else @click="cancelEditNetwork" icon="pi pi-times"
:label="screenWidth > 640 ? t('web.device_management.cancel_edit') : undefined"
:class="['cancel-button', screenWidth <= 640 ? 'p-button-icon-only' : '']"
:style="screenWidth <= 640 ? 'width: 3rem !important; height: 3rem !important; font-size: 1.2rem' : ''"
:tooltip="screenWidth <= 640 ? t('web.device_management.cancel_edit') : undefined"
tooltipOptions="{ position: 'bottom' }" severity="secondary" />
<!-- More actions menu -->
<Menu ref="menuRef" :model="actionMenu" :popup="true" />
<Button v-if="!isEditingNetwork && selectedInstanceId" icon="pi pi-ellipsis-v"
class="p-button-rounded flex items-center justify-center" severity="help"
style="width: 3rem !important; height: 3rem !important; font-size: 1.2rem"
@click="menuRef.toggle($event)" :aria-label="t('web.device_management.more_actions')"
:tooltip="t('web.device_management.more_actions')" tooltipOptions="{ position: 'bottom' }" />
</div>
</div>
</div>
<!-- Main Content Area -->
<div class="network-content bg-surface-0 p-4 rounded-lg shadow-sm">
<!-- Network Creation Form -->
<div v-if="isEditingNetwork || networkIsDisabled" class="network-creation-container">
<div class="network-creation-header flex items-center gap-2 mb-3">
<i class="pi pi-plus-circle text-primary text-xl"></i>
<h2 class="text-xl font-medium">{{ t('web.device_management.edit_network') }}</h2>
</div>
<div class="w-full flex gap-2 flex-wrap justify-start mb-3">
<Button @click="showConfigEditDialog = true" icon="pi pi-file-edit"
:label="t('web.device_management.edit_as_file')" iconPos="left" severity="secondary" />
<Button @click="importConfig" icon="pi pi-upload" :label="t('web.device_management.import_config')"
iconPos="left" severity="help" />
<Button v-if="networkIsDisabled" @click="saveNetworkConfig" icon="pi pi-save"
:label="t('web.device_management.save_config')" iconPos="left" severity="success" />
</div>
<Divider />
<Config :cur-network="currentNetworkConfig" @run-network="saveAndRunNewNetwork"></Config>
</div>
<!-- Network Status (for running networks) -->
<div v-else-if="needShowNetworkStatus" class="network-status-container">
<div class="network-status-header flex items-center gap-2 mb-3">
<i class="pi pi-chart-line text-primary text-xl"></i>
<h2 class="text-xl font-medium">{{ t('web.device_management.network_status') }}</h2>
</div>
<Status v-if="(curNetworkInfo?.error_msg ?? '') === ''" v-bind:cur-network-inst="curNetworkInfo"
class="mb-4">
</Status>
<Message v-else severity="error" class="mb-4">{{ curNetworkInfo?.error_msg }}</Message>
<div class="text-center mt-4">
<Button @click="stopNetwork" :disabled="!currentNetworkControl.deletable.value"
:label="t('web.device_management.disable_network')" severity="danger" icon="pi pi-power-off"
iconPos="left" />
</div>
</div>
<!-- Empty State -->
<div v-else class="empty-state flex flex-col items-center py-12">
<i class="pi pi-sitemap text-5xl text-secondary mb-4 opacity-50"></i>
<div class="text-xl text-center font-medium mb-3">{{ t('web.device_management.no_network_selected') }}
</div>
<p class="text-secondary text-center mb-6 max-w-md">
{{ t('web.device_management.select_existing_network_or_create_new') }}
</p>
<Button @click="newNetwork" :label="t('web.device_management.create_network')" icon="pi pi-plus"
iconPos="left" />
</div>
</div>
<!-- Keep only the config edit dialogs -->
<!-- <ConfigEditDialog v-if="networkIsDisabled" v-model:visible="showCreateNetworkDialog"
:cur-network="currentNetworkConfig" :generate-config="generateConfig" :save-config="saveConfig" /> -->
<ConfigEditDialog v-model:visible="showConfigEditDialog" :cur-network="currentNetworkConfig"
:generate-config="generateConfig" :save-config="syncTomlConfig" />
</div>
</template>
<style scoped>
.device-management {
height: 100%;
display: flex;
flex-direction: column;
}
.network-content {
flex: 1;
overflow-y: auto;
}
/* 按钮样式 */
.button-container {
gap: 0.5rem;
}
.create-button {
font-weight: 600;
min-width: 3rem;
}
/* 菜单样式定制 */
:deep(.p-menu) {
min-width: 12rem;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
padding: 0.25rem;
}
:deep(.p-menu .p-menuitem) {
border-radius: 0.25rem;
}
:deep(.p-menu .p-menuitem-link) {
padding: 0.65rem 1rem;
font-size: 0.9rem;
}
:deep(.p-menu .p-menuitem-icon) {
margin-right: 0.75rem;
}
:deep(.p-menu .p-menuitem.p-error .p-menuitem-text,
.p-menu .p-menuitem.p-error .p-menuitem-icon) {
color: var(--red-500);
}
:deep(.p-menu .p-menuitem:hover.p-error .p-menuitem-link) {
background-color: var(--red-50);
}
/* 按钮图标样式 */
:deep(.p-button-icon-only) {
width: 2.5rem !important;
padding: 0.5rem !important;
}
:deep(.p-button-icon-only .p-button-icon) {
font-size: 1rem;
}
/* 网络选择相关样式 */
.network-label {
white-space: nowrap;
}
:deep(.network-select-container) {
max-width: 100%;
}
/* Dark mode adaptations */
:deep(.bg-surface-50) {
background-color: var(--surface-50, #f8fafc);
}
:deep(.bg-surface-0) {
background-color: var(--surface-card, #ffffff);
}
:deep(.text-primary) {
color: var(--primary-color, #3b82f6);
}
:deep(.text-secondary) {
color: var(--text-color-secondary, #64748b);
}
@media (prefers-color-scheme: dark) {
:deep(.bg-surface-50) {
background-color: var(--surface-ground, #0f172a);
}
:deep(.bg-surface-0) {
background-color: var(--surface-card, #1e293b);
}
}
/* Responsive design for mobile devices */
@media (max-width: 768px) {
.network-header {
padding: 0.75rem;
}
.network-content {
padding: 0.75rem;
}
/* 在小屏幕上缩短网络标签文本 */
.network-label {
font-size: 0.9rem;
}
}
</style>

View File

@@ -1,11 +1,12 @@
<script setup lang="ts">
import { useTimeAgo } from '@vueuse/core'
import { IPv4 } from 'ip-num/IPNumber'
import { NetworkInstance, type NodeInfo, type PeerRoutePair } from '../types/network'
import { NetworkInstance, type TunnelInfo, type NodeInfo, type PeerRoutePair } from '../types/network'
import { useI18n } from 'vue-i18n';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { ipv4InetToString, ipv4ToString, ipv6ToString } from '../modules/utils';
import { DataTable, Column, Tag, Chip, Button, Dialog, ScrollPanel, Timeline, Divider, Card, } from 'primevue';
import { Badge, DataTable, Column, Tag, Chip, Button, Dialog, ScrollPanel, Timeline, Divider, Card, } from 'primevue';
import NetworkChart from './NetworkChart.vue';
const props = defineProps<{
curNetworkInst: NetworkInstance | null,
@@ -21,6 +22,7 @@ const peerRouteInfos = computed(() => {
ipv4_addr: my_node_info?.virtual_ipv4,
hostname: my_node_info?.hostname,
version: my_node_info?.version,
stun_info: my_node_info?.stun_info
},
}, ...(props.curNetworkInst.detail?.peer_route_pairs || [])]
}
@@ -106,8 +108,30 @@ function ipFormat(info: PeerRoutePair) {
return ip ? `${IPv4.fromNumber(ip.address.addr)}/${ip.network_length}` : ''
}
function oneTunnelProto(tunnel?: TunnelInfo): string {
if (!tunnel)
return ''
const local_addr = tunnel.local_addr
let isIPv6 = false;
if (local_addr?.url) {
try {
const urlObj = new URL(local_addr.url, 'http://dummy');
// IPv6 addresses in URLs are enclosed in brackets and contain ':'
isIPv6 = /^\[.*:.*\]$/.test(urlObj.hostname);
} catch (e) {
// fallback to original check if URL parsing fails
isIPv6 = local_addr.url.indexOf('[') >= 0;
}
}
if (isIPv6)
return `${tunnel.tunnel_type}6`
else
return tunnel.tunnel_type
}
function tunnelProto(info: PeerRoutePair) {
return [...new Set(info.peer?.conns.map(c => c.tunnel?.tunnel_type))].join(',')
return [...new Set(info.peer?.conns.map(c => oneTunnelProto(c.tunnel)))].join(',')
}
const myNodeInfo = computed(() => {
@@ -122,6 +146,34 @@ interface Chip {
icon: string
}
// udp nat type
enum NatType {
// has NAT; but own a single public IP, port is not changed
Unknown = 0,
OpenInternet = 1,
NoPAT = 2,
FullCone = 3,
Restricted = 4,
PortRestricted = 5,
Symmetric = 6,
SymUdpFirewall = 7,
SymmetricEasyInc = 8,
SymmetricEasyDec = 9,
};
const udpNatTypeStrMap = {
[NatType.Unknown]: 'Unknown',
[NatType.OpenInternet]: 'Open Internet',
[NatType.NoPAT]: 'No PAT',
[NatType.FullCone]: 'Full Cone',
[NatType.Restricted]: 'Restricted',
[NatType.PortRestricted]: 'Port Restricted',
[NatType.Symmetric]: 'Symmetric',
[NatType.SymUdpFirewall]: 'Symmetric UDP Firewall',
[NatType.SymmetricEasyInc]: 'Symmetric Easy Inc',
[NatType.SymmetricEasyDec]: 'Symmetric Easy Dec',
}
const myNodeInfoChips = computed(() => {
if (!props.curNetworkInst)
return []
@@ -190,35 +242,8 @@ const myNodeInfoChips = computed(() => {
} as Chip)
}
// udp nat type
enum NatType {
// has NAT; but own a single public IP, port is not changed
Unknown = 0,
OpenInternet = 1,
NoPAT = 2,
FullCone = 3,
Restricted = 4,
PortRestricted = 5,
Symmetric = 6,
SymUdpFirewall = 7,
SymmetricEasyInc = 8,
SymmetricEasyDec = 9,
};
const udpNatType: NatType = my_node_info.stun_info?.udp_nat_type
if (udpNatType !== undefined) {
const udpNatTypeStrMap = {
[NatType.Unknown]: 'Unknown',
[NatType.OpenInternet]: 'Open Internet',
[NatType.NoPAT]: 'No PAT',
[NatType.FullCone]: 'Full Cone',
[NatType.Restricted]: 'Restricted',
[NatType.PortRestricted]: 'Port Restricted',
[NatType.Symmetric]: 'Symmetric',
[NatType.SymUdpFirewall]: 'Symmetric UDP Firewall',
[NatType.SymmetricEasyInc]: 'Symmetric Easy Inc',
[NatType.SymmetricEasyDec]: 'Symmetric Easy Dec',
}
chips.push({
label: `UDP NAT Type: ${udpNatTypeStrMap[udpNatType]}`,
icon: '',
@@ -249,6 +274,14 @@ function rxGlobalSum() {
return globalSumCommon('stats.rx_bytes')
}
function natType(info: PeerRoutePair): string {
const udpNatType = info.route?.stun_info?.udp_nat_type;
if (udpNatType !== undefined)
return udpNatTypeStrMap[udpNatType as NatType]
return ''
}
const peerCount = computed(() => {
if (!peerRouteInfos.value)
return 0
@@ -263,6 +296,10 @@ let prevTxSum = 0
let prevRxSum = 0
const txRate = ref('0')
const rxRate = ref('0')
// 控制节点详细信息chips的显示/隐藏
const showNodeDetails = ref(false)
onMounted(() => {
rateIntervalId = window.setInterval(() => {
const curTxSum = txGlobalSum()
@@ -343,36 +380,23 @@ function showEventLogs() {
</template>
<template #content>
<div class="flex w-full flex-col gap-y-5">
<div class="m-0 flex flex-row justify-center gap-x-5">
<div class="rounded-full w-32 h-32 flex flex-col items-center pt-6" style="border: 1px solid green">
<div class="font-bold">
{{ t('peer_count') }}
</div>
<div class="text-5xl mt-1">
{{ peerCount }}
</div>
</div>
<div class="rounded-full w-32 h-32 flex flex-col items-center pt-6" style="border: 1px solid purple">
<div class="font-bold">
{{ t('upload') }}
</div>
<div class="text-xl mt-2">
{{ txRate }}/s
</div>
</div>
<div class="rounded-full w-32 h-32 flex flex-col items-center pt-6" style="border: 1px solid fuchsia">
<div class="font-bold">
{{ t('download') }}
</div>
<div class="text-xl mt-2">
{{ rxRate }}/s
</div>
<div class="gap-4">
<!-- 网络流量图表 -->
<div class="w-full">
<NetworkChart :upload-rate="txRate" :download-rate="rxRate" />
</div>
</div>
<div class="flex flex-row items-center flex-wrap w-full max-h-40 overflow-scroll">
<!-- 展开/收起节点详细信息的divider按钮 -->
<div class="w-full">
<Button @click="showNodeDetails = !showNodeDetails"
:icon="showNodeDetails ? 'pi pi-chevron-up' : 'pi pi-chevron-down'"
:label="showNodeDetails ? t('hide_node_details') : t('show_node_details')" severity="secondary" outlined
class="w-full justify-center" size="small" />
</div>
<!-- 节点详细信息chips根据showNodeDetails状态显示/隐藏 -->
<div v-show="showNodeDetails" class="flex flex-row items-center flex-wrap w-full max-h-40 overflow-scroll">
<Chip v-for="(chip, i) in myNodeInfoChips" :key="i" :label="chip.label" :icon="chip.icon"
class="mr-2 mt-2 text-sm" />
</div>
@@ -389,7 +413,15 @@ function showEventLogs() {
<Card>
<template #title>
{{ t('peer_info') }}
<div class="flex items-center gap-3">
<div class="flex items-center gap-2">
<span>{{ t('peer_info') }}</span>
</div>
<div class="flex items-center gap-1">
<Badge :value="peerCount" severity="info"
class="text-lg font-semibold px-2 py-1 rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200" />
</div>
</div>
</template>
<template #content>
<DataTable :value="peerRouteInfos" column-resize-mode="fit" table-class="w-full">
@@ -417,6 +449,7 @@ function showEventLogs() {
<Column :field="txBytes" :header="t('upload_bytes')" />
<Column :field="rxBytes" :header="t('download_bytes')" />
<Column :field="lossRate" :header="t('loss_rate')" />
<Column :field="natType" :header="t('nat_type')" />
<Column :header="t('status.version')">
<template #body="slotProps">
<span>{{ version(slotProps.data) }}</span>
@@ -433,4 +466,8 @@ function showEventLogs() {
.p-timeline :deep(.p-timeline-event-opposite) {
@apply flex-none;
}
:deep(.p-datatable .p-datatable-column-title) {
white-space: nowrap;
}
</style>

View File

@@ -1,3 +1,4 @@
export { default as Config } from './Config.vue';
export { default as Status } from './Status.vue';
export { default as ConfigEditDialog } from './ConfigEditDialog.vue';
export { default as RemoteManagement } from './RemoteManagement.vue';

View File

@@ -1,8 +1,8 @@
import './style.css'
import type { App } from 'vue';
import { Config, Status, ConfigEditDialog } from "./components";
import Aura from '@primevue/themes/aura'
import { Config, Status, ConfigEditDialog, RemoteManagement } from "./components";
import Aura from '@primeuix/themes/aura';
import PrimeVue from 'primevue/config'
import I18nUtils from './modules/i18n'
@@ -44,8 +44,9 @@ export default {
app.component('ConfigEditDialog', ConfigEditDialog);
app.component('Status', Status);
app.component('HumanEvent', HumanEvent);
app.component('RemoteManagement', RemoteManagement);
app.directive('tooltip', vTooltip as any);
}
};
export { Config, ConfigEditDialog, Status, I18nUtils, NetworkTypes, Api, Utils };
export { Config, ConfigEditDialog, RemoteManagement, Status, I18nUtils, NetworkTypes, Api, Utils };

View File

@@ -44,8 +44,12 @@ logging_open_dir: 打开日志目录
logging_copy_dir: 复制日志路径
disable_auto_launch: 关闭开机自启
enable_auto_launch: 开启开机自启
hide_dock_icon: 隐藏 Dock 图标
show_dock_icon: 显示 Dock 图标
exit: 退出
chips_placeholder: 例如: {0}, 输入后在下拉框中选择生效
show_node_details: 显示节点详细信息
hide_node_details: 隐藏节点详细信息
hostname_placeholder: '留空默认为主机名: {0}'
dev_name_placeholder: 注意当多个网络同时使用相同的TUN接口名称时将会在设置TUN的IP时产生冲突留空以自动生成随机名称
off_text: 点击关闭
@@ -74,6 +78,7 @@ latency: 延迟
upload_bytes: 上传
download_bytes: 下载
loss_rate: 丢包率
nat_type: NAT 类型
flags_switch: 功能开关
@@ -101,6 +106,9 @@ disable_quic_input_help: 禁用 QUIC 入站流量,其他开启 QUIC 代理的
disable_p2p: 禁用 P2P
disable_p2p_help: 禁用 P2P 模式,所有流量通过手动指定的服务器中转。
p2p_only: 仅 P2P
p2p_only_help: 仅与已经建立P2P连接的对等节点通信不通过其他节点中转。
bind_device: 仅使用物理网卡
bind_device_help: 仅使用物理网卡,避免 EasyTier 通过其他虚拟网建立连接。
@@ -159,6 +167,7 @@ port_forwards_help: "将本地端口转发到虚拟网络中的远程端口。
port_forwards_bind_addr: "绑定地址0.0.0.0"
port_forwards_dst_addr: "目标地址10.126.126.1"
port_forwards_add_btn: "添加"
edit_port_forward: "编辑端口转发"
mtu: MTU
mtu_help: |
@@ -210,6 +219,7 @@ event:
ConnectionError: 连接错误
Connecting: 正在连接
ConnectError: 连接错误
VpnPortalStarted: VPN门户已启动
VpnPortalClientConnected: VPN门户客户端已连接
VpnPortalClientDisconnected: VPN门户客户端已断开连接
DhcpIpv4Changed: DHCP IPv4地址更改
@@ -283,9 +293,11 @@ web:
network: 网络
select_network: 选择网络
create_network: 创建网络
cancel_creation: 取消创建
cancel_edit: 取消编辑
more_actions: 更多操作
edit_as_file: 编辑为文件
save_config: 保存配置
config_saved: 配置已保存
import_config: 导入配置
create_new: 创建新网络
network_status: 网络状态
@@ -327,4 +339,4 @@ web:
confirm_password: 确认新密码
language: 语言
theme: 主题
logout: 退出登录
logout: 退出登录

View File

@@ -44,8 +44,12 @@ logging_open_dir: Open Log Directory
logging_copy_dir: Copy Log Path
disable_auto_launch: Disable Launch on Reboot
enable_auto_launch: Enable Launch on Reboot
hide_dock_icon: Hide Dock Icon
show_dock_icon: Show Dock Icon
exit: Exit
use_latency_first: Latency First Mode
show_node_details: Show Node Details
hide_node_details: Hide Node Details
chips_placeholder: 'e.g: {0}, select from the dropdown after input'
hostname_placeholder: 'Leave blank and default to host name: {0}'
dev_name_placeholder: 'Note: When multiple networks use the same TUN interface name at the same time, there will be a conflict when setting the TUN''s IP. Leave blank to automatically generate a random name.'
@@ -73,6 +77,7 @@ latency: Latency
upload_bytes: Upload
download_bytes: Download
loss_rate: Loss Rate
nat_type: NAT Type
flags_switch: Feature Switch
@@ -100,6 +105,9 @@ disable_quic_input_help: Disable inbound QUIC traffic, while nodes with QUIC pro
disable_p2p: Disable P2P
disable_p2p_help: Disable P2P mode; route all traffic through a manually specified relay server.
p2p_only: P2P Only
p2p_only_help: Only communicate with peers that have already established P2P connections, do not relay through other nodes.
bind_device: Bind to Physical Device Only
bind_device_help: Use only the physical network interface to prevent EasyTier from connecting via virtual networks.
@@ -159,6 +167,7 @@ port_forwards_help: "forward local port to remote port in virtual network. e.g.:
port_forwards_bind_addr: "Bind address, e.g.: 0.0.0.0"
port_forwards_dst_addr: "Destination address, e.g.: 10.126.126.1"
port_forwards_add_btn: "Add"
edit_port_forward: "Edit Port Forward"
mtu: MTU
mtu_help: |
@@ -210,6 +219,7 @@ event:
ConnectionError: ConnectionError
Connecting: Connecting
ConnectError: ConnectError
VpnPortalStarted: VpnPortalStarted
VpnPortalClientConnected: VpnPortalClientConnected
VpnPortalClientDisconnected: VpnPortalClientDisconnected
DhcpIpv4Changed: DhcpIpv4Changed
@@ -283,9 +293,11 @@ web:
network: Network
select_network: Select Network
create_network: Create Network
cancel_creation: Cancel Creation
cancel_edit: Cancel Edit
more_actions: More Actions
edit_as_file: Edit as File
save_config: Save Config
config_saved: Config Saved
import_config: Import Config
create_new: Create New Network
network_status: Network Status
@@ -327,4 +339,4 @@ web:
confirm_password: Confirm New Password
language: Language
theme: Theme
logout: Logout
logout: Logout

View File

@@ -1,241 +1,68 @@
import axios, { AxiosError, AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import { Md5 } from 'ts-md5'
import { UUID } from './utils';
import { NetworkConfig } from '../types/network';
import { NetworkConfig, NetworkInstanceRunningInfo } from '../types/network';
export interface ValidateConfigResponse {
toml_config: string;
}
// 定义接口返回的数据结构
export interface LoginResponse {
success: boolean;
message: string;
}
export interface RegisterResponse {
success: boolean;
message: string;
}
// 定义请求体数据结构
export interface Credential {
username: string;
password: string;
}
export interface RegisterData {
credentials: Credential;
captcha: string;
}
export interface Summary {
device_count: number;
}
export interface ListNetworkInstanceIdResponse {
running_inst_ids: Array<UUID>,
disabled_inst_ids: Array<UUID>,
}
export interface GenerateConfigRequest {
config: NetworkConfig;
}
export interface GenerateConfigResponse {
toml_config?: string;
error?: string;
}
export interface ParseConfigRequest {
toml_config: string;
}
export interface ParseConfigResponse {
config?: NetworkConfig;
error?: string;
}
export class ApiClient {
private client: AxiosInstance;
private authFailedCb: Function | undefined;
constructor(baseUrl: string, authFailedCb: Function | undefined = undefined) {
this.client = axios.create({
baseURL: baseUrl + '/api/v1',
withCredentials: true, // 如果需要支持跨域携带cookie
headers: {
'Content-Type': 'application/json',
},
});
this.authFailedCb = authFailedCb;
// 添加请求拦截器
this.client.interceptors.request.use((config: InternalAxiosRequestConfig) => {
return config;
}, (error: any) => {
return Promise.reject(error);
});
// 添加响应拦截器
this.client.interceptors.response.use((response: AxiosResponse) => {
console.debug('Axios Response:', response);
return response.data; // 假设服务器返回的数据都在data属性中
}, (error: any) => {
if (error.response) {
let response: AxiosResponse = error.response;
if (response.status == 401 && this.authFailedCb) {
console.error('Unauthorized:', response.data);
this.authFailedCb();
} else {
// 请求已发出但是服务器响应的状态码不在2xx范围
console.error('Response Error:', error.response.data);
}
} else if (error.request) {
// 请求已发出,但是没有收到响应
console.error('Request Error:', error.request);
} else {
// 发生了一些问题导致请求未发出
console.error('Error:', error.message);
}
return Promise.reject(error);
});
}
// 注册
public async register(data: RegisterData): Promise<RegisterResponse> {
try {
data.credentials.password = Md5.hashStr(data.credentials.password);
const response = await this.client.post<RegisterResponse>('/auth/register', data);
console.log("register response:", response);
return { success: true, message: 'Register success', };
} catch (error) {
if (error instanceof AxiosError) {
return { success: false, message: 'Failed to register, error: ' + JSON.stringify(error.response?.data), };
}
return { success: false, message: 'Unknown error, error: ' + error, };
}
}
// 登录
public async login(data: Credential): Promise<LoginResponse> {
try {
data.password = Md5.hashStr(data.password);
const response = await this.client.post<any>('/auth/login', data);
console.log("login response:", response);
return { success: true, message: 'Login success', };
} catch (error) {
if (error instanceof AxiosError) {
if (error.response?.status === 401) {
return { success: false, message: 'Invalid username or password', };
} else {
return { success: false, message: 'Unknown error, status code: ' + error.response?.status, };
}
}
return { success: false, message: 'Unknown error, error: ' + error, };
}
}
public async logout() {
await this.client.get('/auth/logout');
if (this.authFailedCb) {
this.authFailedCb();
}
}
public async change_password(new_password: string) {
await this.client.put('/auth/password', { new_password: Md5.hashStr(new_password) });
}
public async check_login_status() {
try {
await this.client.get('/auth/check_login_status');
return true;
} catch (error) {
return false;
}
}
public async list_session() {
const response = await this.client.get('/sessions');
return response;
}
public async list_machines(): Promise<Array<any>> {
const response = await this.client.get<any, Record<string, Array<any>>>('/machines');
return response.machines;
}
public async list_deivce_instance_ids(machine_id: string): Promise<ListNetworkInstanceIdResponse> {
const response = await this.client.get<any, ListNetworkInstanceIdResponse>('/machines/' + machine_id + '/networks');
return response;
}
public async update_device_instance_state(machine_id: string, inst_id: string, disabled: boolean): Promise<undefined> {
await this.client.put<string>('/machines/' + machine_id + '/networks/' + inst_id, {
disabled: disabled,
});
}
public async get_network_info(machine_id: string, inst_id: string): Promise<any> {
const response = await this.client.get<any, Record<string, any>>('/machines/' + machine_id + '/networks/info/' + inst_id);
return response.info.map;
}
public async get_network_config(machine_id: string, inst_id: string): Promise<any> {
const response = await this.client.get<any, Record<string, any>>('/machines/' + machine_id + '/networks/config/' + inst_id);
return response;
}
public async validate_config(machine_id: string, config: any): Promise<ValidateConfigResponse> {
const response = await this.client.post<any, ValidateConfigResponse>(`/machines/${machine_id}/validate-config`, {
config: config,
});
return response;
}
public async run_network(machine_id: string, config: any): Promise<undefined> {
await this.client.post<string>(`/machines/${machine_id}/networks`, {
config: config,
});
}
public async delete_network(machine_id: string, inst_id: string): Promise<undefined> {
await this.client.delete<string>(`/machines/${machine_id}/networks/${inst_id}`);
}
public async get_summary(): Promise<Summary> {
const response = await this.client.get<any, Summary>('/summary');
return response;
}
public captcha_url() {
return this.client.defaults.baseURL + '/auth/captcha';
}
public async generate_config(config: GenerateConfigRequest): Promise<GenerateConfigResponse> {
try {
const response = await this.client.post<any, GenerateConfigResponse>('/generate-config', config);
return response;
} catch (error) {
if (error instanceof AxiosError) {
return { error: error.response?.data };
}
return { error: 'Unknown error: ' + error };
}
}
public async parse_config(config: ParseConfigRequest): Promise<ParseConfigResponse> {
try {
const response = await this.client.post<any, ParseConfigResponse>('/parse-config', config);
return response;
} catch (error) {
if (error instanceof AxiosError) {
return { error: error.response?.data };
}
return { error: 'Unknown error: ' + error };
}
export interface CollectNetworkInfoResponse {
info: {
map: Record<string, NetworkInstanceRunningInfo | undefined>;
}
}
export default ApiClient;
export namespace ConfigFilePermission {
export type Flags = number;
export const READ_ONLY: Flags = 1 << 0;
export const NO_DELETE: Flags = 1 << 1;
export function hasPermission(perm: Flags, flag: Flags): boolean {
return (perm & flag) === flag;
}
export function isRemoveSaveable(perm: Flags): boolean {
return !hasPermission(perm, NO_DELETE);
}
export function isEditable(perm: Flags): boolean {
return !hasPermission(perm, READ_ONLY);
}
export function isDeletable(perm: Flags): boolean {
return !hasPermission(perm, NO_DELETE);
}
}
export interface NetworkMeta {
network_name: string;
config_permission: ConfigFilePermission.Flags;
}
export interface GetNetworkMetasResponse {
metas: Record<string, NetworkMeta>;
}
export interface RemoteClient {
validate_config(config: NetworkConfig): Promise<ValidateConfigResponse>;
run_network(config: NetworkConfig, save: boolean): Promise<undefined>;
get_network_info(inst_id: string): Promise<NetworkInstanceRunningInfo | undefined>;
list_network_instance_ids(): Promise<ListNetworkInstanceIdResponse>;
delete_network(inst_id: string): Promise<undefined>;
update_network_instance_state(inst_id: string, disabled: boolean): Promise<undefined>;
save_config(config: NetworkConfig): Promise<undefined>;
get_network_config(inst_id: string): Promise<NetworkConfig>;
generate_config(config: NetworkConfig): Promise<GenerateConfigResponse>;
parse_config(toml_config: string): Promise<ParseConfigResponse>;
get_network_metas(instance_ids: string[]): Promise<GetNetworkMetasResponse>;
}

View File

@@ -31,7 +31,6 @@ export interface NetworkConfig {
advanced_settings: boolean
listener_urls: string[]
rpc_port: number
latency_first: boolean
dev_name: string
@@ -43,6 +42,7 @@ export interface NetworkConfig {
enable_quic_proxy?: boolean
disable_quic_input?: boolean
disable_p2p?: boolean
p2p_only?: boolean
bind_device?: boolean
no_tun?: boolean
enable_exit_node?: boolean
@@ -70,8 +70,6 @@ export interface NetworkConfig {
enable_magic_dns?: boolean
enable_private_mode?: boolean
rpc_portal_whitelists: string[]
port_forwards: PortForwardConfig[]
}
@@ -104,7 +102,6 @@ export function DEFAULT_NETWORK_CONFIG(): NetworkConfig {
'udp://0.0.0.0:11010',
'wg://0.0.0.0:11011',
],
rpc_port: 0,
latency_first: false,
dev_name: '',
@@ -115,6 +112,7 @@ export function DEFAULT_NETWORK_CONFIG(): NetworkConfig {
enable_quic_proxy: false,
disable_quic_input: false,
disable_p2p: false,
p2p_only: false,
bind_device: true,
no_tun: false,
enable_exit_node: false,
@@ -135,7 +133,6 @@ export function DEFAULT_NETWORK_CONFIG(): NetworkConfig {
mapped_listeners: [],
enable_magic_dns: false,
enable_private_mode: false,
rpc_portal_whitelists: [],
port_forwards: [],
}
}
@@ -246,10 +243,14 @@ export interface PeerRoutePair {
peer?: PeerInfo
}
export interface UrlPb {
url: string
}
export interface TunnelInfo {
tunnel_type: string
local_addr: string
remote_addr: string
local_addr: UrlPb
remote_addr: UrlPb
}
export interface PeerConnStats {
@@ -302,6 +303,7 @@ export enum EventType {
Connecting = 'Connecting', // any
ConnectError = 'ConnectError', // string, string, string
VpnPortalStarted = 'VpnPortalStarted', // string
VpnPortalClientConnected = 'VpnPortalClientConnected', // string, string
VpnPortalClientDisconnected = 'VpnPortalClientDisconnected', // string, string, string

View File

@@ -24,12 +24,13 @@ export default defineConfig({
},
// make sure to externalize deps that shouldn't be bundled
// into your library
external: ['vue'],
external: ['vue', 'primevue'],
output: {
// Provide global variables to use in the UMD build
// for externalized deps
globals: {
vue: 'Vue',
primevue: 'primevue',
},
exports: "named"
},

View File

@@ -9,18 +9,19 @@
"preview": "vite preview"
},
"dependencies": {
"@primevue/themes": "4.3.3",
"aura": "link:@primevue/themes/aura",
"@modyfi/vite-plugin-yaml": "^1.1.0",
"@primeuix/themes": "^1.2.3",
"axios": "^1.7.7",
"easytier-frontend-lib": "workspace:*",
"primevue": "4.3.3",
"primevue": "^4.3.9",
"tailwindcss-primeui": "^0.3.4",
"ts-md5": "^1.3.1",
"vue": "^3.5.12",
"vue-router": "4",
"vue-i18n": "^9.9.1",
"@modyfi/vite-plugin-yaml": "^1.1.0"
"vue-router": "4"
},
"devDependencies": {
"@primevue/auto-import-resolver": "4.3.9",
"@types/node": "^22.8.6",
"@vitejs/plugin-vue": "^5.1.4",
"autoprefixer": "^10.4.20",

View File

@@ -1,11 +1,11 @@
<script lang="ts" setup>
import { computed, inject, ref } from 'vue';
import { Card, Password, Button } from 'primevue';
import { Api } from 'easytier-frontend-lib';
import ApiClient from '../modules/api';
const dialogRef = inject<any>('dialogRef');
const api = computed<Api.ApiClient>(() => dialogRef.value.data.api);
const api = computed<ApiClient>(() => dialogRef.value.data.api);
const password = ref('');

View File

@@ -1,11 +1,11 @@
<script setup lang="ts">
import { NetworkTypes } from 'easytier-frontend-lib';
import {computed, ref} from 'vue';
import { Api } from 'easytier-frontend-lib'
import {AutoComplete, Divider, Button, Textarea} from "primevue";
import {getInitialApiHost, cleanAndLoadApiHosts, saveApiHost} from "../modules/api-host"
import { computed, ref } from 'vue';
import { AutoComplete, Divider, Button, Textarea } from "primevue";
import { getInitialApiHost, cleanAndLoadApiHosts, saveApiHost } from "../modules/api-host"
import ApiClient from '../modules/api';
const api = computed<Api.ApiClient>(() => new Api.ApiClient(apiHost.value));
const api = computed<ApiClient>(() => new ApiClient(apiHost.value));
const apiHost = ref<string>(getInitialApiHost())
const apiHostSuggestions = ref<Array<string>>([])
@@ -27,28 +27,24 @@ const errorMessage = ref<string>("");
const generateConfig = (config: NetworkTypes.NetworkConfig) => {
saveApiHost(apiHost.value)
errorMessage.value = "";
api.value?.generate_config({
config: config
}).then((res) => {
if (res.error) {
errorMessage.value = "Generation failed: " + res.error;
} else if (res.toml_config) {
toml_config.value = res.toml_config;
} else {
errorMessage.value = "Api server returned an unexpected response";
}
}).catch(err => {
errorMessage.value = "Generate request failed: " + (err instanceof Error ? err.message : String(err));
});
api.value?.get_remote_client("").generate_config(config).then((res) => {
if (res.error) {
errorMessage.value = "Generation failed: " + res.error;
} else if (res.toml_config) {
toml_config.value = res.toml_config;
} else {
errorMessage.value = "Api server returned an unexpected response";
}
}).catch(err => {
errorMessage.value = "Generate request failed: " + (err instanceof Error ? err.message : String(err));
});
};
const parseConfig = async () => {
try {
errorMessage.value = "";
const res = await api.value?.parse_config({
toml_config: toml_config.value
});
const res = await api.value?.get_remote_client("").parse_config(toml_config.value);
if (res.error) {
errorMessage.value = "Parse failed: " + res.error;
} else if (res.config) {
@@ -64,31 +60,29 @@ const parseConfig = async () => {
</script>
<template>
<div class="flex items-center justify-center m-5">
<div class="sm:block md:flex w-full">
<div class="sm:w-full md:w-1/2 p-4">
<div class="flex flex-col">
<div class="w-11/12 self-center ">
<label>ApiHost</label>
<AutoComplete id="api-host" v-model="apiHost" dropdown :suggestions="apiHostSuggestions"
@complete="apiHostSearch" class="w-full" />
<Divider />
</div>
</div>
<Config :cur-network="newNetworkConfig" @run-network="generateConfig" />
</div>
<div class="sm:w-full md:w-1/2 p-4 flex flex-col h-[calc(100vh-80px)]">
<pre v-if="errorMessage" class="mb-2 p-2 rounded text-sm overflow-auto bg-red-100 text-red-700 max-h-40">{{ errorMessage }}</pre>
<Textarea
v-model="toml_config"
spellcheck="false"
class="w-full flex-grow p-2 whitespace-pre-wrap font-mono resize-none"
placeholder="Press 'Run Network' to generate TOML configuration, or paste your TOML configuration here to parse it"
></Textarea>
<div class="mt-3 flex justify-center">
<Button label="Parse Config" icon="pi pi-arrow-left" icon-pos="left" @click="parseConfig" />
</div>
</div>
<div class="flex items-center justify-center m-5">
<div class="sm:block md:flex w-full">
<div class="sm:w-full md:w-1/2 p-4">
<div class="flex flex-col">
<div class="w-full self-center ">
<label>ApiHost</label>
<AutoComplete id="api-host" v-model="apiHost" dropdown :suggestions="apiHostSuggestions"
@complete="apiHostSearch" class="w-full" />
<Divider />
</div>
</div>
<Config :cur-network="newNetworkConfig" @run-network="generateConfig" />
</div>
<div class="sm:w-full md:w-1/2 p-4 flex flex-col h-[calc(100vh-80px)]">
<pre v-if="errorMessage"
class="mb-2 p-2 rounded text-sm overflow-auto bg-red-100 text-red-700 max-h-40">{{ errorMessage }}</pre>
<Textarea v-model="toml_config" spellcheck="false"
class="w-full flex-grow p-2 whitespace-pre-wrap font-mono resize-none"
placeholder="Press 'Run Network' to generate TOML configuration, or paste your TOML configuration here to parse it"></Textarea>
<div class="mt-3 flex justify-center">
<Button label="Parse Config" icon="pi pi-arrow-left" icon-pos="left" @click="parseConfig" />
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,15 +1,16 @@
<script setup lang="ts">
import { Card, useToast } from 'primevue';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { Api, Utils } from 'easytier-frontend-lib';
import { Utils } from 'easytier-frontend-lib';
import ApiClient, { Summary } from '../modules/api';
const props = defineProps({
api: Api.ApiClient,
api: ApiClient,
});
const toast = useToast();
const summary = ref<Api.Summary | undefined>(undefined);
const summary = ref<Summary | undefined>(undefined);
const loadSummary = async () => {
const resp = await props.api?.get_summary();

View File

@@ -3,9 +3,10 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { Button, Drawer, ProgressSpinner, useToast, InputSwitch, Popover, Dropdown, Toolbar } from 'primevue';
import Tooltip from 'primevue/tooltip';
import { useRoute, useRouter } from 'vue-router';
import { Api, Utils } from 'easytier-frontend-lib';
import { Utils } from 'easytier-frontend-lib';
import DeviceDetails from './DeviceDetails.vue';
import { useI18n } from 'vue-i18n'
import ApiClient from '../modules/api';
const { t } = useI18n()
@@ -15,7 +16,7 @@ declare const window: Window & typeof globalThis;
const vTooltip = Tooltip;
const props = defineProps({
api: Api.ApiClient,
api: ApiClient,
});
const detailPopover = ref();

View File

@@ -1,14 +1,12 @@
<script setup lang="ts">
import { IftaLabel, Select, Button, ConfirmPopup, useConfirm, useToast, Divider, Menu } from 'primevue';
import { NetworkTypes, Status, Utils, Api, ConfigEditDialog } from 'easytier-frontend-lib';
import { watch, computed, onMounted, onUnmounted, ref } from 'vue';
import { NetworkTypes, Utils, Api, RemoteManagement } from 'easytier-frontend-lib';
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n'
import ApiClient from '../modules/api';
const { t } = useI18n()
const props = defineProps<{
api: Api.ApiClient;
api: ApiClient;
deviceList: Array<Utils.DeviceInfo> | undefined;
}>();
@@ -16,7 +14,6 @@ const emits = defineEmits(['update']);
const route = useRoute();
const router = useRouter();
const toast = useToast();
const deviceId = computed<string>(() => {
return route.params.deviceId as string;
@@ -30,469 +27,29 @@ const deviceInfo = computed<Utils.DeviceInfo | undefined | null>(() => {
return deviceId.value ? props.deviceList?.find((device) => device.machine_id === deviceId.value) : null;
});
const configFile = ref();
const curNetworkInfo = ref<NetworkTypes.NetworkInstance | null>(null);
const isEditing = ref(false);
const showCreateNetworkDialog = ref(false);
const showConfigEditDialog = ref(false);
const isCreatingNetwork = ref(false); // Flag to indicate if we're in network creation mode
const newNetworkConfig = ref<NetworkTypes.NetworkConfig>(NetworkTypes.DEFAULT_NETWORK_CONFIG());
const listInstanceIdResponse = ref<Api.ListNetworkInstanceIdResponse | undefined>(undefined);
const instanceIdList = computed(() => {
let insts = new Set(deviceInfo.value?.running_network_instances || []);
let t = listInstanceIdResponse.value;
if (t) {
t.running_inst_ids.forEach((u) => insts.add(Utils.UuidToStr(u)));
t.disabled_inst_ids.forEach((u) => insts.add(Utils.UuidToStr(u)));
}
let options = Array.from(insts).map((instance: string) => {
return { uuid: instance };
});
return options;
});
const selectedInstanceId = computed({
get() {
return instanceIdList.value.find((instance) => instance.uuid === instanceId.value);
return instanceId.value;
},
set(value: any) {
console.log("set instanceId", value);
router.push({ name: 'deviceManagement', params: { deviceId: deviceId.value, instanceId: value.uuid } });
set(value: string) {
console.log("selectedInstanceId", value);
router.push({ name: 'deviceManagement', params: { deviceId: deviceId.value, instanceId: value } });
}
});
const needShowNetworkStatus = computed(() => {
if (!selectedInstanceId.value) {
// nothing selected
return false;
}
if (networkIsDisabled.value) {
// network is disabled
return false;
}
return true;
})
const remoteClient = computed<Api.RemoteClient>(() => props.api.get_remote_client(deviceId.value));
const networkIsDisabled = computed(() => {
if (!selectedInstanceId.value) {
return false;
}
return listInstanceIdResponse.value?.disabled_inst_ids.map(Utils.UuidToStr).includes(selectedInstanceId.value?.uuid);
});
watch(selectedInstanceId, async (newVal, oldVal) => {
if (newVal?.uuid !== oldVal?.uuid && networkIsDisabled.value) {
await loadDisabledNetworkConfig();
}
});
const disabledNetworkConfig = ref<NetworkTypes.NetworkConfig | undefined>(undefined);
const loadDisabledNetworkConfig = async () => {
disabledNetworkConfig.value = undefined;
if (!deviceId.value || !selectedInstanceId.value) {
return;
}
let ret = await props.api?.get_network_config(deviceId.value, selectedInstanceId.value.uuid);
disabledNetworkConfig.value = ret;
const newConfigGenerator = () => {
const config = NetworkTypes.DEFAULT_NETWORK_CONFIG();
config.hostname = deviceInfo.value?.hostname;
return config;
}
const updateNetworkState = async (disabled: boolean) => {
if (!deviceId.value || !selectedInstanceId.value) {
return;
}
if (disabled || !disabledNetworkConfig.value) {
await props.api?.update_device_instance_state(deviceId.value, selectedInstanceId.value.uuid, disabled);
} else if (disabledNetworkConfig.value) {
await props.api?.delete_network(deviceId.value, disabledNetworkConfig.value.instance_id);
await props.api?.run_network(deviceId.value, disabledNetworkConfig.value);
}
await loadNetworkInstanceIds();
}
const confirm = useConfirm();
const confirmDeleteNetwork = (event: any) => {
confirm.require({
target: event.currentTarget,
message: 'Do you want to delete this network?',
icon: 'pi pi-info-circle',
rejectProps: {
label: 'Cancel',
severity: 'secondary',
outlined: true
},
acceptProps: {
label: 'Delete',
severity: 'danger'
},
accept: async () => {
try {
await props.api?.delete_network(deviceId.value, instanceId.value);
} catch (e) {
console.error(e);
}
emits('update');
},
reject: () => {
return;
}
});
};
// const verifyNetworkConfig = async (): Promise<ValidateConfigResponse | undefined> => {
// let ret = await props.api?.validate_config(deviceId.value, newNetworkConfig.value);
// console.log("verifyNetworkConfig", ret);
// return ret;
// }
const createNewNetwork = async () => {
try {
if (isEditing.value) {
await props.api?.delete_network(deviceId.value, instanceId.value);
}
let ret = await props.api?.run_network(deviceId.value, newNetworkConfig.value);
console.debug("createNewNetwork", ret);
} catch (e: any) {
console.error(e);
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to create network, error: ' + JSON.stringify(e.response.data), life: 2000 });
return;
}
emits('update');
showCreateNetworkDialog.value = false;
isCreatingNetwork.value = false; // Exit creation mode after successful network creation
}
const newNetwork = () => {
newNetworkConfig.value = NetworkTypes.DEFAULT_NETWORK_CONFIG();
newNetworkConfig.value.hostname = deviceInfo.value?.hostname;
isEditing.value = false;
// showCreateNetworkDialog.value = true; // Old dialog approach
isCreatingNetwork.value = true; // Switch to creation mode instead
}
const cancelNetworkCreation = () => {
isCreatingNetwork.value = false;
}
const editNetwork = async () => {
if (!deviceId.value || !instanceId.value) {
toast.add({ severity: 'error', summary: 'Error', detail: 'No network instance selected', life: 2000 });
return;
}
isEditing.value = true;
try {
let ret = await props.api?.get_network_config(deviceId.value, instanceId.value);
console.debug("editNetwork", ret);
newNetworkConfig.value = ret;
// showCreateNetworkDialog.value = true; // Old dialog approach
isCreatingNetwork.value = true; // Switch to creation mode instead
} catch (e: any) {
console.error(e);
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to edit network, error: ' + JSON.stringify(e.response.data), life: 2000 });
return;
}
}
const loadNetworkInstanceIds = async () => {
if (!deviceId.value) {
return;
}
listInstanceIdResponse.value = await props.api?.list_deivce_instance_ids(deviceId.value);
console.debug("loadNetworkInstanceIds", listInstanceIdResponse.value);
}
const loadDeviceInfo = async () => {
if (!deviceId.value || !instanceId.value) {
return;
}
let ret = await props.api?.get_network_info(deviceId.value, instanceId.value);
let device_info = ret[instanceId.value];
curNetworkInfo.value = {
instance_id: instanceId.value,
running: device_info.running,
error_msg: device_info.error_msg,
detail: device_info,
} as NetworkTypes.NetworkInstance;
}
const exportConfig = async () => {
if (!deviceId.value || !instanceId.value) {
toast.add({ severity: 'error', summary: 'Error', detail: 'No network instance selected', life: 2000 });
return;
}
try {
let networkConfig = await props.api?.get_network_config(deviceId.value, instanceId.value);
delete networkConfig.instance_id;
let { toml_config: tomlConfig, error } = await props.api?.generate_config({
config: networkConfig
});
if (error) {
throw { response: { data: error } };
}
exportTomlFile(tomlConfig ?? '', instanceId.value + '.toml');
} catch (e: any) {
console.error(e);
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to export network config, error: ' + JSON.stringify(e.response.data), life: 2000 });
return;
}
}
const importConfig = () => {
configFile.value.click();
}
const handleFileUpload = (event: Event) => {
const files = (event.target as HTMLInputElement).files;
const file = files ? files[0] : null;
if (!file) return;
const reader = new FileReader();
reader.onload = async (e) => {
try {
let tomlConfig = e.target?.result?.toString();
if (!tomlConfig) return;
const resp = await props.api?.parse_config({ toml_config: tomlConfig });
if (resp.error) {
throw resp.error;
}
const config = resp.config;
if (!config) return;
config.instance_id = newNetworkConfig.value?.instance_id ?? config?.instance_id;
Object.assign(newNetworkConfig.value, resp.config);
toast.add({ severity: 'success', summary: 'Import Success', detail: "Config file import success", life: 2000 });
} catch (error) {
toast.add({ severity: 'error', summary: 'Error', detail: 'Config file parse error: ' + error, life: 2000 });
}
configFile.value.value = null;
}
reader.readAsText(file);
}
const exportTomlFile = (context: string, name: string) => {
let url = window.URL.createObjectURL(new Blob([context], { type: 'application/toml' }));
let link = document.createElement('a');
link.style.display = 'none';
link.href = url;
link.setAttribute('download', name);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
}
const generateConfig = async (config: NetworkTypes.NetworkConfig): Promise<string> => {
let { toml_config: tomlConfig, error } = await props.api?.generate_config({ config });
if (error) {
throw error;
}
return tomlConfig ?? '';
}
const saveConfig = async (tomlConfig: string): Promise<void> => {
let resp = await props.api?.parse_config({ toml_config: tomlConfig });
if (resp.error) {
throw resp.error;
};
const config = resp.config;
if (!config) {
throw new Error("Parsed config is empty");
}
config.instance_id = disabledNetworkConfig.value?.instance_id ?? config?.instance_id;
if (networkIsDisabled.value) {
disabledNetworkConfig.value = config;
} else {
newNetworkConfig.value = config;
}
}
// 响应式屏幕宽度
const screenWidth = ref(window.innerWidth);
const updateScreenWidth = () => {
screenWidth.value = window.innerWidth;
};
// 菜单引用和菜单项
const menuRef = ref();
const actionMenu = ref([
{
label: t('web.device_management.edit_network'),
icon: 'pi pi-pencil',
command: () => editNetwork()
},
{
label: t('web.device_management.export_config'),
icon: 'pi pi-download',
command: () => exportConfig()
},
{
label: t('web.device_management.delete_network'),
icon: 'pi pi-trash',
class: 'p-error',
command: () => confirmDeleteNetwork(new Event('click'))
}
]);
let periodFunc = new Utils.PeriodicTask(async () => {
try {
await Promise.all([loadNetworkInstanceIds(), loadDeviceInfo()]);
} catch (e) {
console.debug(e);
}
}, 1000);
onMounted(async () => {
periodFunc.start();
// 添加屏幕尺寸监听
window.addEventListener('resize', updateScreenWidth);
});
onUnmounted(() => {
periodFunc.stop();
// 移除屏幕尺寸监听
window.removeEventListener('resize', updateScreenWidth);
});
</script>
<template>
<div class="device-management">
<input type="file" @change="handleFileUpload" class="hidden" accept="application/toml" ref="configFile" />
<ConfirmPopup></ConfirmPopup>
<!-- 网络选择和操作按钮始终在同一行 -->
<div class="network-header bg-surface-50 p-3 rounded-lg shadow-sm mb-1">
<div class="flex flex-row justify-between items-center gap-2" style="align-items: center;">
<!-- 网络选择 -->
<div class="flex-1 min-w-0">
<IftaLabel class="w-full">
<Select v-model="selectedInstanceId" :options="instanceIdList" optionLabel="uuid" class="w-full"
inputId="dd-inst-id" :placeholder="t('web.device_management.select_network')"
:pt="{ root: { class: 'network-select-container' } }" />
<label class="network-label mr-2 font-medium" for="dd-inst-id">{{
t('web.device_management.network') }}</label>
</IftaLabel>
</div>
<!-- 简化的按钮区域 - 无论屏幕大小都显示 -->
<div class="flex gap-2 shrink-0 button-container items-center">
<!-- Create/Cancel button based on state -->
<Button v-if="!isCreatingNetwork" @click="newNetwork" icon="pi pi-plus"
:label="screenWidth > 640 ? t('web.device_management.create_new') : undefined"
:class="['create-button', screenWidth <= 640 ? 'p-button-icon-only' : '']"
:style="screenWidth <= 640 ? 'width: 3rem !important; height: 3rem !important; font-size: 1.2rem' : ''"
:tooltip="screenWidth <= 640 ? t('web.device_management.create_network') : undefined"
tooltipOptions="{ position: 'bottom' }" severity="primary" />
<Button v-else @click="cancelNetworkCreation" icon="pi pi-times"
:label="screenWidth > 640 ? t('web.device_management.cancel_creation') : undefined"
:class="['cancel-button', screenWidth <= 640 ? 'p-button-icon-only' : '']"
:style="screenWidth <= 640 ? 'width: 3rem !important; height: 3rem !important; font-size: 1.2rem' : ''"
:tooltip="screenWidth <= 640 ? t('web.device_management.cancel_creation') : undefined"
tooltipOptions="{ position: 'bottom' }" severity="secondary" />
<!-- More actions menu -->
<Menu ref="menuRef" :model="actionMenu" :popup="true" />
<Button v-if="!isCreatingNetwork && selectedInstanceId" icon="pi pi-ellipsis-v"
class="p-button-rounded flex items-center justify-center" severity="help"
style="width: 3rem !important; height: 3rem !important; font-size: 1.2rem"
@click="menuRef.toggle($event)" :aria-label="t('web.device_management.more_actions')"
:tooltip="t('web.device_management.more_actions')" tooltipOptions="{ position: 'bottom' }" />
</div>
</div>
</div>
<!-- Main Content Area -->
<div class="network-content bg-surface-0 p-4 rounded-lg shadow-sm">
<!-- Network Creation Form -->
<div v-if="isCreatingNetwork" class="network-creation-container">
<div class="network-creation-header flex items-center gap-2 mb-3">
<i class="pi pi-plus-circle text-primary text-xl"></i>
<h2 class="text-xl font-medium">{{ isEditing ? t('web.device_management.edit_network') :
t('web.device_management.create_network') }}</h2>
</div>
<div class="w-full flex gap-2 flex-wrap justify-start mb-3">
<Button @click="showConfigEditDialog = true" icon="pi pi-file-edit"
:label="t('web.device_management.edit_as_file')" iconPos="left" severity="secondary" />
<Button @click="importConfig" icon="pi pi-upload" :label="t('web.device_management.import_config')"
iconPos="left" severity="help" />
</div>
<Divider />
<Config :cur-network="newNetworkConfig" @run-network="createNewNetwork"></Config>
</div>
<!-- Network Status (for running networks) -->
<div v-else-if="needShowNetworkStatus" class="network-status-container">
<div class="network-status-header flex items-center gap-2 mb-3">
<i class="pi pi-chart-line text-primary text-xl"></i>
<h2 class="text-xl font-medium">{{ t('web.device_management.network_status') }}</h2>
</div>
<Status v-bind:cur-network-inst="curNetworkInfo" class="mb-4"></Status>
<div class="text-center mt-4">
<Button @click="updateNetworkState(true)" :label="t('web.device_management.disable_network')"
severity="warning" icon="pi pi-power-off" iconPos="left" />
</div>
</div>
<!-- Network Configuration (for disabled networks) -->
<div v-else-if="networkIsDisabled" class="network-config-container">
<div class="network-config-header flex items-center gap-2 mb-3">
<i class="pi pi-cog text-secondary text-xl"></i>
<h2 class="text-xl font-medium">{{ t('web.device_management.network_configuration') }}</h2>
</div>
<div v-if="disabledNetworkConfig" class="mb-4">
<Config :cur-network="disabledNetworkConfig" @run-network="updateNetworkState(false)" />
</div>
<div v-else class="network-loading-placeholder text-center py-8">
<i class="pi pi-spin pi-spinner text-3xl text-primary mb-3"></i>
<div class="text-xl text-secondary">{{ t('web.device_management.loading_network_configuration') }}
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="empty-state flex flex-col items-center py-12">
<i class="pi pi-sitemap text-5xl text-secondary mb-4 opacity-50"></i>
<div class="text-xl text-center font-medium mb-3">{{ t('web.device_management.no_network_selected') }}
</div>
<p class="text-secondary text-center mb-6 max-w-md">
{{ t('web.device_management.select_existing_network_or_create_new') }}
</p>
<Button @click="newNetwork" :label="t('web.device_management.create_network')" icon="pi pi-plus"
iconPos="left" />
</div>
</div>
<!-- Keep only the config edit dialogs -->
<ConfigEditDialog v-if="networkIsDisabled" v-model:visible="showCreateNetworkDialog"
:cur-network="disabledNetworkConfig" :generate-config="generateConfig" :save-config="saveConfig" />
<ConfigEditDialog v-else v-model:visible="showConfigEditDialog" :cur-network="newNetworkConfig"
:generate-config="generateConfig" :save-config="saveConfig" />
</div>
<RemoteManagement :api="remoteClient" v-model:instance-id="selectedInstanceId"
:new-config-generator="newConfigGenerator" />
</template>
<style scoped>

View File

@@ -3,9 +3,10 @@ import { computed, onMounted, ref } from 'vue';
import { Card, InputText, Password, Button, AutoComplete } from 'primevue';
import { useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { Api, I18nUtils } from 'easytier-frontend-lib';
import { I18nUtils } from 'easytier-frontend-lib';
import { getInitialApiHost, cleanAndLoadApiHosts, saveApiHost } from "../modules/api-host"
import { useI18n } from 'vue-i18n'
import ApiClient, { Credential, RegisterData } from '../modules/api';
const { t } = useI18n()
@@ -13,7 +14,7 @@ defineProps<{
isRegistering: boolean;
}>();
const api = computed<Api.ApiClient>(() => new Api.ApiClient(apiHost.value));
const api = computed<ApiClient>(() => new ApiClient(apiHost.value));
const router = useRouter();
const toast = useToast();
@@ -28,7 +29,7 @@ const captchaSrc = computed(() => api.value.captcha_url());
const onSubmit = async () => {
// Add your login logic here
saveApiHost(apiHost.value);
const credential: Api.Credential = { username: username.value, password: password.value, };
const credential: Credential = { username: username.value, password: password.value, };
let ret = await api.value?.login(credential);
if (ret.success) {
localStorage.setItem('apiHost', btoa(apiHost.value));
@@ -43,8 +44,8 @@ const onSubmit = async () => {
const onRegister = async () => {
saveApiHost(apiHost.value);
const credential: Api.Credential = { username: registerUsername.value, password: registerPassword.value };
const registerReq: Api.RegisterData = { credentials: credential, captcha: captcha.value };
const credential: Credential = { username: registerUsername.value, password: registerPassword.value };
const registerReq: RegisterData = { credentials: credential, captcha: captcha.value };
let ret = await api.value?.register(registerReq);
if (ret.success) {
toast.add({ severity: 'success', summary: 'Register Success', detail: ret.message, life: 2000 });
@@ -108,12 +109,12 @@ onMounted(() => {
<form v-else @submit.prevent="onRegister" class="space-y-4">
<div class="p-field">
<label for="register-username" class="block text-sm font-medium">{{ t('web.login.username')
}}</label>
}}</label>
<InputText id="register-username" v-model="registerUsername" required class="w-full" />
</div>
<div class="p-field">
<label for="register-password" class="block text-sm font-medium">{{ t('web.login.password')
}}</label>
}}</label>
<Password id="register-password" v-model="registerPassword" required toggleMask
:feedback="false" class="w-full" />
</div>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Api, I18nUtils } from 'easytier-frontend-lib'
import { I18nUtils } from 'easytier-frontend-lib'
import { computed, onMounted, ref, onUnmounted, nextTick } from 'vue';
import { Button, TieredMenu } from 'primevue';
import { useRoute, useRouter } from 'vue-router';
@@ -7,13 +7,14 @@ import { useDialog } from 'primevue/usedialog';
import ChangePassword from './ChangePassword.vue';
import Icon from '../assets/easytier.png'
import { useI18n } from 'vue-i18n'
import ApiClient from '../modules/api';
const { t } = useI18n()
const route = useRoute();
const router = useRouter();
const api = computed<Api.ApiClient | undefined>(() => {
const api = computed<ApiClient | undefined>(() => {
try {
return new Api.ApiClient(atob(route.params.apiHost as string), () => {
return new ApiClient(atob(route.params.apiHost as string), () => {
router.push({ name: 'login' });
})
} catch (e) {

View File

@@ -4,7 +4,7 @@ import './style.css'
import App from './App.vue'
import EasytierFrontendLib from 'easytier-frontend-lib'
import PrimeVue from 'primevue/config'
import Aura from '@primevue/themes/aura'
import Aura from '@primeuix/themes/aura';
import ConfirmationService from 'primevue/confirmationservice';
import { I18nUtils } from 'easytier-frontend-lib'

View File

@@ -0,0 +1,255 @@
import axios, { AxiosError, AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import { type Api, type NetworkTypes, Utils } from 'easytier-frontend-lib';
import { Md5 } from 'ts-md5';
export interface ValidateConfigResponse {
toml_config: string;
}
// 定义接口返回的数据结构
export interface LoginResponse {
success: boolean;
message: string;
}
export interface RegisterResponse {
success: boolean;
message: string;
}
// 定义请求体数据结构
export interface Credential {
username: string;
password: string;
}
export interface RegisterData {
credentials: Credential;
captcha: string;
}
export interface Summary {
device_count: number;
}
export interface ListNetworkInstanceIdResponse {
running_inst_ids: Array<Utils.UUID>,
disabled_inst_ids: Array<Utils.UUID>,
}
export interface GenerateConfigRequest {
config: NetworkTypes.NetworkConfig;
}
export interface GenerateConfigResponse {
toml_config?: string;
error?: string;
}
export interface ParseConfigRequest {
toml_config: string;
}
export interface ParseConfigResponse {
config?: NetworkTypes.NetworkConfig;
error?: string;
}
export class ApiClient {
private client: AxiosInstance;
private authFailedCb: Function | undefined;
constructor(baseUrl: string, authFailedCb: Function | undefined = undefined) {
this.client = axios.create({
baseURL: baseUrl + '/api/v1',
withCredentials: true, // 如果需要支持跨域携带cookie
headers: {
'Content-Type': 'application/json',
},
});
this.authFailedCb = authFailedCb;
// 添加请求拦截器
this.client.interceptors.request.use((config: InternalAxiosRequestConfig) => {
return config;
}, (error: any) => {
return Promise.reject(error);
});
// 添加响应拦截器
this.client.interceptors.response.use((response: AxiosResponse) => {
console.debug('Axios Response:', response);
return response.data; // 假设服务器返回的数据都在data属性中
}, (error: any) => {
if (error.response) {
let response: AxiosResponse = error.response;
if (response.status == 401 && this.authFailedCb) {
console.error('Unauthorized:', response.data);
this.authFailedCb();
} else {
// 请求已发出但是服务器响应的状态码不在2xx范围
console.error('Response Error:', error.response.data);
}
} else if (error.request) {
// 请求已发出,但是没有收到响应
console.error('Request Error:', error.request);
} else {
// 发生了一些问题导致请求未发出
console.error('Error:', error.message);
}
return Promise.reject(error);
});
}
// 注册
public async register(data: RegisterData): Promise<RegisterResponse> {
try {
data.credentials.password = Md5.hashStr(data.credentials.password);
const response = await this.client.post<RegisterResponse>('/auth/register', data);
console.log("register response:", response);
return { success: true, message: 'Register success', };
} catch (error) {
if (error instanceof AxiosError) {
return { success: false, message: 'Failed to register, error: ' + JSON.stringify(error.response?.data), };
}
return { success: false, message: 'Unknown error, error: ' + error, };
}
}
// 登录
public async login(data: Credential): Promise<LoginResponse> {
try {
data.password = Md5.hashStr(data.password);
const response = await this.client.post<any>('/auth/login', data);
console.log("login response:", response);
return { success: true, message: 'Login success', };
} catch (error) {
if (error instanceof AxiosError) {
if (error.response?.status === 401) {
return { success: false, message: 'Invalid username or password', };
} else {
return { success: false, message: 'Unknown error, status code: ' + error.response?.status, };
}
}
return { success: false, message: 'Unknown error, error: ' + error, };
}
}
public async logout() {
await this.client.get('/auth/logout');
if (this.authFailedCb) {
this.authFailedCb();
}
}
public async change_password(new_password: string) {
await this.client.put('/auth/password', { new_password: Md5.hashStr(new_password) });
}
public async check_login_status() {
try {
await this.client.get('/auth/check_login_status');
return true;
} catch (error) {
return false;
}
}
public async list_session() {
const response = await this.client.get('/sessions');
return response;
}
public async list_machines(): Promise<Array<any>> {
const response = await this.client.get<any, Record<string, Array<any>>>('/machines');
return response.machines;
}
public async get_summary(): Promise<Summary> {
const response = await this.client.get<any, Summary>('/summary');
return response;
}
public captcha_url() {
return this.client.defaults.baseURL + '/auth/captcha';
}
public get_remote_client(machine_id: string): Api.RemoteClient {
return new WebRemoteClient(machine_id, this.client);
}
}
class WebRemoteClient implements Api.RemoteClient {
private machine_id: string;
private client: AxiosInstance;
constructor(machine_id: string, client: AxiosInstance) {
this.machine_id = machine_id;
this.client = client;
}
async validate_config(config: NetworkTypes.NetworkConfig): Promise<Api.ValidateConfigResponse> {
const response = await this.client.post<NetworkTypes.NetworkConfig, ValidateConfigResponse>(`/machines/${this.machine_id}/validate-config`, {
config: config,
});
return response;
}
async run_network(config: NetworkTypes.NetworkConfig, save: boolean): Promise<undefined> {
await this.client.post<string>(`/machines/${this.machine_id}/networks`, {
config: config,
save: save
});
}
async get_network_info(inst_id: string): Promise<NetworkTypes.NetworkInstanceRunningInfo | undefined> {
const response = await this.client.get<any, Api.CollectNetworkInfoResponse>('/machines/' + this.machine_id + '/networks/info/' + inst_id);
return response.info.map[inst_id];
}
async list_network_instance_ids(): Promise<Api.ListNetworkInstanceIdResponse> {
const response = await this.client.get<any, ListNetworkInstanceIdResponse>('/machines/' + this.machine_id + '/networks');
return response;
}
async delete_network(inst_id: string): Promise<undefined> {
await this.client.delete<string>(`/machines/${this.machine_id}/networks/${inst_id}`);
}
async update_network_instance_state(inst_id: string, disabled: boolean): Promise<undefined> {
await this.client.put<string>('/machines/' + this.machine_id + '/networks/' + inst_id, {
disabled: disabled,
});
}
async save_config(config: NetworkTypes.NetworkConfig): Promise<undefined> {
await this.client.put(`/machines/${this.machine_id}/networks/config/${config.instance_id}`, { config });
}
async get_network_config(inst_id: string): Promise<NetworkTypes.NetworkConfig> {
const response = await this.client.get<any, NetworkTypes.NetworkConfig>('/machines/' + this.machine_id + '/networks/config/' + inst_id);
return response;
}
async generate_config(config: NetworkTypes.NetworkConfig): Promise<Api.GenerateConfigResponse> {
try {
const response = await this.client.post<any, GenerateConfigResponse>('/generate-config', { config });
return response;
} catch (error) {
if (error instanceof AxiosError) {
return { error: error.response?.data };
}
return { error: 'Unknown error: ' + error };
}
}
async parse_config(toml_config: string): Promise<Api.ParseConfigResponse> {
try {
const response = await this.client.post<any, ParseConfigResponse>('/parse-config', { toml_config });
return response;
} catch (error) {
if (error instanceof AxiosError) {
return { error: error.response?.data };
}
return { error: 'Unknown error: ' + error };
}
}
async get_network_metas(instance_ids: string[]): Promise<Api.GetNetworkMetasResponse> {
const response = await this.client.post<any, Api.GetNetworkMetasResponse>(`/machines/${this.machine_id}/networks/metas`, {
instance_ids: instance_ids
});
return response;
}
}
export default ApiClient;

View File

@@ -7,13 +7,19 @@ use std::sync::{
};
use dashmap::DashMap;
use easytier::{proto::web::HeartbeatRequest, tunnel::TunnelListener};
use easytier::{
proto::{
api::manage::WebClientService, rpc_types::controller::BaseController, web::HeartbeatRequest,
},
rpc_service::remote_client::{self, RemoteClientManager},
tunnel::TunnelListener,
};
use maxminddb::geoip2;
use session::{Location, Session};
use storage::{Storage, StorageToken};
use tokio::task::JoinSet;
use crate::db::{Db, UserIdInDb};
use crate::db::{entity::user_running_network_configs, Db, UserIdInDb};
#[derive(rust_embed::Embed)]
#[folder = "resources/"]
@@ -152,7 +158,7 @@ impl ClientManager {
s.data().read().await.location().cloned()
}
pub fn db(&self) -> &Db {
fn db(&self) -> &Db {
self.storage.db()
}
@@ -245,11 +251,38 @@ impl ClientManager {
}
}
impl
RemoteClientManager<
(UserIdInDb, uuid::Uuid),
user_running_network_configs::Model,
sea_orm::DbErr,
> for ClientManager
{
fn get_rpc_client(
&self,
(user_id, machine_id): (UserIdInDb, uuid::Uuid),
) -> Option<Box<dyn WebClientService<Controller = BaseController> + Send>> {
let s = self.get_session_by_machine_id(user_id, &machine_id)?;
Some(s.scoped_rpc_client())
}
fn get_storage(
&self,
) -> &impl remote_client::Storage<
(UserIdInDb, uuid::Uuid),
user_running_network_configs::Model,
sea_orm::DbErr,
> {
self.storage.db()
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use std::{sync::Arc, time::Duration};
use easytier::{
instance_manager::NetworkInstanceManager,
tunnel::{
common::tests::wait_for_condition,
udp::{UdpTunnelConnector, UdpTunnelListener},
@@ -273,7 +306,12 @@ mod tests {
.unwrap();
let connector = UdpTunnelConnector::new("udp://127.0.0.1:54333".parse().unwrap());
let _c = WebClient::new(connector, "test", "test");
let _c = WebClient::new(
connector,
"test",
"test",
Arc::new(NetworkInstanceManager::new()),
);
wait_for_condition(
|| async { mgr.client_sessions.len() == 1 },

View File

@@ -4,20 +4,19 @@ use anyhow::Context;
use easytier::{
common::scoped_task::ScopedTask,
proto::{
api::manage::{
NetworkConfig, RunNetworkInstanceRequest, WebClientService,
WebClientServiceClientFactory,
},
rpc_impl::bidirect::BidirectRpcManager,
rpc_types::{self, controller::BaseController},
web::{
HeartbeatRequest, HeartbeatResponse, NetworkConfig, RunNetworkInstanceRequest,
WebClientService, WebClientServiceClientFactory, WebServerService,
WebServerServiceServer,
},
web::{HeartbeatRequest, HeartbeatResponse, WebServerService, WebServerServiceServer},
},
rpc_service::remote_client::{ListNetworkProps, Storage as _},
tunnel::Tunnel,
};
use tokio::sync::{broadcast, RwLock};
use crate::db::ListNetworkProps;
use super::storage::{Storage, StorageToken, WeakRefStorage};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
@@ -224,10 +223,10 @@ impl Session {
}
let req = req.unwrap();
if req.machine_id.is_none() {
let Some(machine_id) = req.machine_id else {
tracing::warn!(?req, "Machine id is not set, ignore");
continue;
}
};
let running_inst_ids = req
.running_network_instances
@@ -257,11 +256,7 @@ impl Session {
let local_configs = match storage
.db
.list_network_configs(
user_id,
Some(req.machine_id.unwrap().into()),
ListNetworkProps::EnabledOnly,
)
.list_network_configs((user_id, machine_id.into()), ListNetworkProps::EnabledOnly)
.await
{
Ok(configs) => configs,
@@ -285,6 +280,7 @@ impl Session {
config: Some(
serde_json::from_str::<NetworkConfig>(&c.network_config).unwrap(),
),
overwrite: false,
},
)
.await;

View File

@@ -1,5 +1,6 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0
use easytier::{launcher::NetworkConfig, rpc_service::remote_client::PersistentConfig};
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
@@ -39,3 +40,12 @@ impl Related<super::users::Entity> for Entity {
}
impl ActiveModelBehavior for ActiveModel {}
impl PersistentConfig<DbErr> for Model {
fn get_network_inst_id(&self) -> &str {
&self.network_instance_id
}
fn get_network_config(&self) -> Result<NetworkConfig, DbErr> {
serde_json::from_str(&self.network_config).map_err(|e| DbErr::Json(e.to_string()))
}
}

View File

@@ -2,6 +2,10 @@
#[allow(unused_imports)]
pub mod entity;
use easytier::{
launcher::NetworkConfig,
rpc_service::remote_client::{ListNetworkProps, Storage},
};
use entity::user_running_network_configs;
use sea_orm::{
prelude::Expr, sea_query::OnConflict, ColumnTrait as _, DatabaseConnection, DbErr, EntityTrait,
@@ -9,17 +13,13 @@ use sea_orm::{
};
use sea_orm_migration::MigratorTrait as _;
use sqlx::{migrate::MigrateDatabase as _, types::chrono, Sqlite, SqlitePool};
use uuid::Uuid;
use crate::migrator;
use async_trait::async_trait;
pub type UserIdInDb = i32;
pub enum ListNetworkProps {
All,
EnabledOnly,
DisabledOnly,
}
#[derive(Debug, Clone)]
pub struct Db {
db_path: String,
@@ -68,136 +68,6 @@ impl Db {
&self.orm_db
}
pub async fn insert_or_update_user_network_config<T: ToString>(
&self,
user_id: UserIdInDb,
device_id: uuid::Uuid,
network_inst_id: uuid::Uuid,
network_config: T,
) -> Result<(), DbErr> {
let txn = self.orm_db().begin().await?;
use entity::user_running_network_configs as urnc;
let on_conflict = OnConflict::column(urnc::Column::NetworkInstanceId)
.update_columns([
urnc::Column::NetworkConfig,
urnc::Column::Disabled,
urnc::Column::UpdateTime,
])
.to_owned();
let insert_m = urnc::ActiveModel {
user_id: sea_orm::Set(user_id),
device_id: sea_orm::Set(device_id.to_string()),
network_instance_id: sea_orm::Set(network_inst_id.to_string()),
network_config: sea_orm::Set(network_config.to_string()),
disabled: sea_orm::Set(false),
create_time: sea_orm::Set(chrono::Local::now().fixed_offset()),
update_time: sea_orm::Set(chrono::Local::now().fixed_offset()),
..Default::default()
};
urnc::Entity::insert(insert_m)
.on_conflict(on_conflict)
.do_nothing()
.exec(&txn)
.await?;
txn.commit().await
}
pub async fn delete_network_config(
&self,
user_id: UserIdInDb,
network_inst_id: uuid::Uuid,
) -> Result<(), DbErr> {
use entity::user_running_network_configs as urnc;
urnc::Entity::delete_many()
.filter(urnc::Column::UserId.eq(user_id))
.filter(urnc::Column::NetworkInstanceId.eq(network_inst_id.to_string()))
.exec(self.orm_db())
.await?;
Ok(())
}
pub async fn update_network_config_state(
&self,
user_id: UserIdInDb,
network_inst_id: uuid::Uuid,
disabled: bool,
) -> Result<entity::user_running_network_configs::Model, DbErr> {
use entity::user_running_network_configs as urnc;
urnc::Entity::update_many()
.filter(urnc::Column::UserId.eq(user_id))
.filter(urnc::Column::NetworkInstanceId.eq(network_inst_id.to_string()))
.col_expr(urnc::Column::Disabled, Expr::value(disabled))
.col_expr(
urnc::Column::UpdateTime,
Expr::value(chrono::Local::now().fixed_offset()),
)
.exec(self.orm_db())
.await?;
urnc::Entity::find()
.filter(urnc::Column::UserId.eq(user_id))
.filter(urnc::Column::NetworkInstanceId.eq(network_inst_id.to_string()))
.one(self.orm_db())
.await?
.ok_or(DbErr::RecordNotFound(format!(
"Network config not found for user {} and network instance {}",
user_id, network_inst_id
)))
}
pub async fn list_network_configs(
&self,
user_id: UserIdInDb,
device_id: Option<uuid::Uuid>,
props: ListNetworkProps,
) -> Result<Vec<user_running_network_configs::Model>, DbErr> {
use entity::user_running_network_configs as urnc;
let configs = urnc::Entity::find().filter(urnc::Column::UserId.eq(user_id));
let configs = if matches!(
props,
ListNetworkProps::EnabledOnly | ListNetworkProps::DisabledOnly
) {
configs
.filter(urnc::Column::Disabled.eq(matches!(props, ListNetworkProps::DisabledOnly)))
} else {
configs
};
let configs = if let Some(device_id) = device_id {
configs.filter(urnc::Column::DeviceId.eq(device_id.to_string()))
} else {
configs
};
let configs = configs.all(self.orm_db()).await?;
Ok(configs)
}
pub async fn get_network_config(
&self,
user_id: UserIdInDb,
device_id: &uuid::Uuid,
network_inst_id: &String,
) -> Result<Option<user_running_network_configs::Model>, DbErr> {
use entity::user_running_network_configs as urnc;
let config = urnc::Entity::find()
.filter(urnc::Column::UserId.eq(user_id))
.filter(urnc::Column::DeviceId.eq(device_id.to_string()))
.filter(urnc::Column::NetworkInstanceId.eq(network_inst_id))
.one(self.orm_db())
.await?;
Ok(config)
}
pub async fn get_user_id<T: ToString>(
&self,
user_name: T,
@@ -221,8 +91,136 @@ impl Db {
}
}
#[async_trait]
impl Storage<(UserIdInDb, Uuid), user_running_network_configs::Model, DbErr> for Db {
async fn insert_or_update_user_network_config(
&self,
(user_id, device_id): (UserIdInDb, Uuid),
network_inst_id: Uuid,
network_config: NetworkConfig,
) -> Result<(), DbErr> {
let txn = self.orm_db().begin().await?;
use entity::user_running_network_configs as urnc;
let on_conflict = OnConflict::column(urnc::Column::NetworkInstanceId)
.update_columns([
urnc::Column::NetworkConfig,
urnc::Column::Disabled,
urnc::Column::UpdateTime,
])
.to_owned();
let insert_m = urnc::ActiveModel {
user_id: sea_orm::Set(user_id),
device_id: sea_orm::Set(device_id.to_string()),
network_instance_id: sea_orm::Set(network_inst_id.to_string()),
network_config: sea_orm::Set(
serde_json::to_string(&network_config).map_err(|e| DbErr::Json(e.to_string()))?,
),
disabled: sea_orm::Set(false),
create_time: sea_orm::Set(chrono::Local::now().fixed_offset()),
update_time: sea_orm::Set(chrono::Local::now().fixed_offset()),
..Default::default()
};
urnc::Entity::insert(insert_m)
.on_conflict(on_conflict)
.do_nothing()
.exec(&txn)
.await?;
txn.commit().await
}
async fn delete_network_configs(
&self,
(user_id, _): (UserIdInDb, Uuid),
network_inst_ids: &[Uuid],
) -> Result<(), DbErr> {
use entity::user_running_network_configs as urnc;
urnc::Entity::delete_many()
.filter(urnc::Column::UserId.eq(user_id))
.filter(
urnc::Column::NetworkInstanceId
.is_in(network_inst_ids.iter().map(|id| id.to_string())),
)
.exec(self.orm_db())
.await?;
Ok(())
}
async fn update_network_config_state(
&self,
(user_id, _): (UserIdInDb, Uuid),
network_inst_id: Uuid,
disabled: bool,
) -> Result<(), DbErr> {
use entity::user_running_network_configs as urnc;
urnc::Entity::update_many()
.filter(urnc::Column::UserId.eq(user_id))
.filter(urnc::Column::NetworkInstanceId.eq(network_inst_id.to_string()))
.col_expr(urnc::Column::Disabled, Expr::value(disabled))
.col_expr(
urnc::Column::UpdateTime,
Expr::value(chrono::Local::now().fixed_offset()),
)
.exec(self.orm_db())
.await?;
Ok(())
}
async fn list_network_configs(
&self,
(user_id, device_id): (UserIdInDb, Uuid),
props: ListNetworkProps,
) -> Result<Vec<user_running_network_configs::Model>, DbErr> {
use entity::user_running_network_configs as urnc;
let configs = urnc::Entity::find().filter(urnc::Column::UserId.eq(user_id));
let configs = if matches!(
props,
ListNetworkProps::EnabledOnly | ListNetworkProps::DisabledOnly
) {
configs
.filter(urnc::Column::Disabled.eq(matches!(props, ListNetworkProps::DisabledOnly)))
} else {
configs
};
let configs = if !device_id.is_nil() {
configs.filter(urnc::Column::DeviceId.eq(device_id.to_string()))
} else {
configs
};
let configs = configs.all(self.orm_db()).await?;
Ok(configs)
}
async fn get_network_config(
&self,
(user_id, device_id): (UserIdInDb, Uuid),
network_inst_id: &str,
) -> Result<Option<user_running_network_configs::Model>, DbErr> {
use entity::user_running_network_configs as urnc;
let config = urnc::Entity::find()
.filter(urnc::Column::UserId.eq(user_id))
.filter(urnc::Column::DeviceId.eq(device_id.to_string()))
.filter(urnc::Column::NetworkInstanceId.eq(network_inst_id))
.one(self.orm_db())
.await?;
Ok(config)
}
}
#[cfg(test)]
mod tests {
use easytier::{proto::api::manage::NetworkConfig, rpc_service::remote_client::Storage};
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter as _};
use crate::db::{entity::user_running_network_configs, Db, ListNetworkProps};
@@ -231,11 +229,15 @@ mod tests {
async fn test_user_network_config_management() {
let db = Db::memory_db().await;
let user_id = 1;
let network_config = "test_config";
let network_config = NetworkConfig {
network_name: Some("test_config".to_string()),
..Default::default()
};
let network_config_json = serde_json::to_string(&network_config).unwrap();
let inst_id = uuid::Uuid::new_v4();
let device_id = uuid::Uuid::new_v4();
db.insert_or_update_user_network_config(user_id, device_id, inst_id, network_config)
db.insert_or_update_user_network_config((user_id, device_id), inst_id, network_config)
.await
.unwrap();
@@ -246,11 +248,15 @@ mod tests {
.unwrap()
.unwrap();
println!("{:?}", result);
assert_eq!(result.network_config, network_config);
assert_eq!(result.network_config, network_config_json);
// overwrite the config
let network_config = "test_config2";
db.insert_or_update_user_network_config(user_id, device_id, inst_id, network_config)
let network_config = NetworkConfig {
network_name: Some("test_config2".to_string()),
..Default::default()
};
let network_config_json = serde_json::to_string(&network_config).unwrap();
db.insert_or_update_user_network_config((user_id, device_id), inst_id, network_config)
.await
.unwrap();
@@ -261,20 +267,22 @@ mod tests {
.unwrap()
.unwrap();
println!("device: {}, {:?}", device_id, result2);
assert_eq!(result2.network_config, network_config);
assert_eq!(result2.network_config, network_config_json);
assert_eq!(result.create_time, result2.create_time);
assert_ne!(result.update_time, result2.update_time);
assert_eq!(
db.list_network_configs(user_id, Some(device_id), ListNetworkProps::All)
db.list_network_configs((user_id, device_id), ListNetworkProps::All)
.await
.unwrap()
.len(),
1
);
db.delete_network_config(user_id, inst_id).await.unwrap();
db.delete_network_configs((user_id, device_id), &[inst_id])
.await
.unwrap();
let result3 = user_running_network_configs::Entity::find()
.filter(user_running_network_configs::Column::UserId.eq(user_id))
.one(db.orm_db())

Some files were not shown because too many files have changed in this diff Show More