Compare commits

..

181 Commits

Author SHA1 Message Date
KKRainbow 8e1d079142 feat: add Windows UDP broadcast relay (#2222)
This may helps games to find rooms in virtual network.

- add opt-in Windows UDP broadcast relay config flag and CLI/env plumbing
- capture local UDP broadcasts with Windows raw sockets, normalize packets, and inject them via PeerManager
2026-05-09 09:56:31 +08:00
fanyang 55f15bb6f0 fix(connector): classify manual reconnect timeouts by stage (#2062) 2026-05-08 22:08:51 +08:00
Luna Yao 96fd39649a revert UPX version to 4.2.4 in core.yml (#2221) 2026-05-07 18:49:40 +08:00
KKRainbow 74fc8b300d chore: bump version to 2.6.4 (#2219) 2026-05-07 13:48:51 +08:00
KKRainbow baeee40b79 fix machine uid and easytier-web panic (#2215)
1. fix(web-client): persist and migrate machine id
2. fix panic when easytier-web session receive malformat packet
2026-05-07 00:57:42 +08:00
fanyang 4342c8d7a2 fix: add missing CLI help text (#2213) 2026-05-05 17:05:34 +08:00
KKRainbow 1178b312fa fix foreign network entry leak (#2211) 2026-05-05 11:01:44 +08:00
fanyang 362aa7a9cd fix: allow omitted ACL config fields (#2206) 2026-05-04 00:47:24 +08:00
KKRainbow 12a7b5a5c5 fix: scope peer center server data to instance (#2198)
Stop sharing PeerCenterServer state through a process-global map so local and foreign-network services cannot mix peer-center data when peer ids overlap.
2026-05-02 01:43:01 +08:00
fanyang 4eba9b07b6 fix(web-client): keep retrying unreachable config server (#2140)
Defer config-server connector creation into the web client retry loop so
service startup does not fail when network or DNS is unavailable.
2026-05-02 00:09:48 +08:00
KKRainbow 1b48029bdc fix: clean stale foreign network state (#2197)
- clear foreign-network traffic metric peer caches on peer removal and network cleanup
- release reserved foreign-network peer IDs on handshake/add-peer error paths
- avoid creating no-op foreign-network token buckets when limits are unlimited
- shrink relay/session maps after cleanup and remove unused peer-center global data entries
2026-05-01 23:30:51 +08:00
KKRainbow 3542e944cb fix(quic): prune stopped endpoints from pool (#2195)
* remove wss port 0 compatibility code
* fix(quic): prune stopped endpoints from pool
2026-05-01 18:51:39 +08:00
KKRainbow 852d1c9e14 feat(gui): add UPnP and public IPv6 advanced options (#2194)
Expose disable-upnp and ipv6_public_addr_auto in the shared web/GUI config editor
bump release metadata to 2.6.3.
2026-05-01 13:45:19 +08:00
KKRainbow 4958394469 fix: protect self peer during credential refresh and allow need-p2p peers through public server (#2192)
* fix: protect self peer during credential refresh

* fix: allow need-p2p peers through public server
2026-05-01 06:59:30 +08:00
KKRainbow 41b6d65604 fix faketcp filter on windows (#2190) 2026-04-30 23:55:56 +08:00
KKRainbow aae30894dd fix: keep file logger disabled by default (#2189) 2026-04-30 21:42:30 +08:00
fanyang 81d169abfc fix: fall back when CLI manage service is unavailable (#2185) 2026-04-30 19:50:50 +08:00
Luna Yao 9c6c210e89 fix: disable SO_EXCLUSIVEADDRUSE on Windows (#2180) 2026-04-30 19:48:54 +08:00
Mg Pig d1c6dcf754 fix: prevent URL input layout flicker with container queries (#2186) 2026-04-30 19:45:01 +08:00
KKRainbow 97c8c4f55a feat: support disabling relay data forwarding (#2188)
- add a disable_relay_data runtime/config patch option
- reuse the existing avoid_relay_data feature flag when relay data forwarding is disabled
2026-04-30 19:44:40 +08:00
KKRainbow ed8df2d58f prevent EasyTier-managed IPv6 from being used as underlay connections (#2181)
When a node has public IPv6 addresses allocated by EasyTier, those addresses
are installed on the host's network interfaces. The system would then pick
them up as candidate source/destination addresses for underlay connections
(direct peer, UDP hole punch, bind addresses), causing overlay traffic to
loop back into the overlay itself.

Add a central predicate is_ip_easytier_managed_ipv6() and apply it at every
point where IPv6 addresses are selected for underlay use:
- Filter managed IPv6 from DNS-resolved connector addresses, including a
  UDP socket getsockname check to detect whether the OS would route through
  the overlay to reach a destination
- Skip managed IPv6 in bind address selection and STUN candidate filtering
- Strip managed IPv6 from GetIpListResponse RPC so peers never learn them
- Pass pre-resolved addresses to tunnel connectors to avoid re-resolution

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 12:17:22 +08:00
lurenjia f66010e6f9 fix: preserve URL type in matches_scheme (#2179)
Avoid resolving Url::as_ref() to the full URL string before TunnelScheme
conversion. Add regression coverage for owned/borrowed URLs and the UDP
IPv6 hole-punch branch condition.

Co-authored-by: KKRainbow <443152178@qq.com>
2026-04-28 23:23:41 +08:00
Luna Yao d5c4700d32 utils: replace defer, ContextGuard, DetachableTask with guarden crate (#2163) 2026-04-27 18:29:46 +08:00
KKRainbow 969ecfc4ca fix(gui): refresh service after core version upgrade (#2172) 2026-04-27 15:54:52 +08:00
KKRainbow 8f862997eb feat: support allocating public IPv6 addresses from a provider (#2162)
* feat: support allocating public IPv6 addresses from a provider

Add a provider/leaser architecture for public IPv6 address allocation
between nodes in the same network:

- A node with `--ipv6-public-addr-provider` advertises a delegable
  public IPv6 prefix (auto-detected from kernel routes or manually
  configured via `--ipv6-public-addr-prefix`).
- Other nodes with `--ipv6-public-addr-auto` request a /128 lease from
  the selected provider via a new RPC service (PublicIpv6AddrRpc).
- Leases have a 30s TTL, renewed every 10s by the client routine.
- The provider allocates addresses deterministically from its prefix
  using instance-UUID-based hashing to prefer stable assignments.
- Routes to peer leases are installed on the TUN device, and each
  client's own /128 is assigned as its IPv6 address.

Also includes netlink IPv6 route table inspection, integration tests,
and event-driven route/address reconciliation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 21:37:34 +08:00
KKRainbow b20075e3dc fix: allow self virtual IP loopback (#2161) 2026-04-25 21:26:16 +08:00
Luna Yao eb3b5aae51 utils: add DetachableTask & ContextGuard (#2138) 2026-04-25 18:24:36 +08:00
datayurei af6b6ab6f1 fix: avoid panic when validating mapped listeners (#2153) 2026-04-25 17:45:57 +08:00
Luna Yao 5a1668c753 refactor: remove ScopedTask (#2125)
* replace ScopedTask with AbortOnDropHandle
2026-04-25 15:20:25 +08:00
Luna Yao 820d9095d3 replace AsyncRuntime with simpler CancellableTask (#2136) 2026-04-25 10:29:53 +08:00
KKRainbow 2fb41ccbba bump version 262 (#2158) 2026-04-25 10:22:24 +08:00
Luna Yao b4666be696 fix: disable SO_REUSEADDR & enable SO_EXCLUSIVEADDRUSE on Windows (#2128) 2026-04-25 00:37:34 +08:00
KKRainbow 4688ad74ad Honor credential reusable flag (#2157)
- propagate reusable through credential storage, CLI, RPC, routing, and tests
- enforce reusable=false owner election with current topology
- preserve proof-backed groups when refreshing credential ACL groups
2026-04-25 00:22:40 +08:00
Luna Yao f7ea78d4f0 lower max_udp_payload_size to 1200 (#2156) 2026-04-24 21:20:37 +08:00
james.zhang ac112440c3 fix(UrlInput): update parseUrl and buildUrlValue to handle null ports correctly (#2146) 2026-04-23 13:45:09 +08:00
KKRainbow 958b246f05 improve webclient (#2151) 2026-04-23 13:44:18 +08:00
james.zhang 263f4c3bc9 fix(peer_route): exclude current peer ID from proxy CIDR lists (#2149) 2026-04-22 20:30:38 +08:00
Luna Yao ffddc517e1 fix: listener parsing (#2143)
Fixes a CLI listener parsing regression where url crate special-casing for ws/wss could misinterpret inputs like ws:11011, and adds coverage to prevent future regressions.

Changes:

Refactors listener parsing to avoid url::Url parsing for proto:port forms and to support additional shorthand inputs (port-only / IP-only / SocketAddr).
Centralizes “expand to all IpScheme variants” logic in a helper (gen_listeners) while preserving the “port=0 is dynamic” behavior.
Adds unit tests covering valid/invalid listener inputs and expansion behavior.
2026-04-21 23:45:22 +08:00
Debugger Chen 5cd0a3e846 feat: add upnp support (#1449) 2026-04-21 17:19:04 +08:00
Luna Yao f4319c4d4f ci(test): always check everything (#2142)
* ci(test): always check everything
* move Cargo.lock check to the last step
2026-04-21 10:08:27 +08:00
Luna Yao 0091a535d5 use mimalloc for FreeBSD (#2144) 2026-04-21 08:40:21 +08:00
Luna Yao d7a5fb8d66 remove --no-deps from lock check (#2134) 2026-04-20 00:46:26 +08:00
KKRainbow f63054e937 fix: resolve Android APK version fallback to 1.0 on CI (#2131) 2026-04-19 19:06:37 +08:00
KKRainbow efc043abbb bump version to v2.6.1 (#2129) 2026-04-19 16:49:45 +08:00
Mg Pig 40c6de8e31 fix(core): restrict implicit config merge to explicit config files (#2127) 2026-04-19 10:39:04 +08:00
KKRainbow 2db655bd6d fix: refresh ACL groups and enable TCP_NODELAY for WebSocket (#2118)
* fix: refresh ACL groups and enable TCP_NODELAY for WebSocket
* add remove_peers to remove list of peer id in ospf route
* fix secure tunnel for unreliable udp tunnel
* fix(web-client): timeout secure tunnel handshake
* fix(web-server): tolerate delayed secure hello
* fix quic endpoint panic
* fix replay check
2026-04-19 10:37:39 +08:00
Mg Pig c49c56612b feat(ui): add ACL graphical configuration interface (#1815) 2026-04-18 20:23:53 +08:00
Mg Pig 6ca074abae feat(nix): 添加 rustfmt 和 clippy 到 Rust 工具链扩展 (#2126) 2026-04-18 20:23:26 +08:00
Luna Yao 84430055ab remove hashbrown (#2108) 2026-04-18 11:06:34 +08:00
Mg Pig 432fcb3fc3 build(nix): add mold to the flake dev shell (#2122) 2026-04-18 09:06:45 +08:00
Luna Yao fae32361f2 chore: update Rust to 1.95; replace cfg_if with cfg_select (#2121) 2026-04-17 23:41:31 +08:00
Luna Yao bcb2e512d4 utils: move code to a dedicated mod; add AsyncRuntime (#2072) 2026-04-16 23:32:07 +08:00
Luna Yao 82ca04a8a7 proto(utils): add MessageModel & RepeatedMessageModel (#2068)
* add FromIterator, Extend, AsRef, AsMut, TryFrom<[Message]>
2026-04-15 19:40:09 +08:00
Luna Yao 2ef3b72224 proto: add some conversion for Url (#2067) 2026-04-15 19:39:24 +08:00
Luna Yao 6d319cba1d tests(relay_peer_e2e_encryption): wait for the key of inst3 before ping test (#2069) 2026-04-15 19:39:00 +08:00
Luna Yao 3687519ef3 turn off ansi for file log (#2110)
Co-authored-by: KKRainbow <443152178@qq.com>
2026-04-15 19:38:27 +08:00
Luna Yao 3a4ac59467 log: change default log level of tests to WARNING (#2113) 2026-04-14 18:10:38 +08:00
Luna Yao 1cfc135df3 ci: remove -D warnings from test (#2109)
Co-authored-by: KKRainbow <443152178@qq.com>
2026-04-14 12:35:05 +08:00
KKRainbow 5b35c51da9 fix packet split on udp tunnel and avoid tcp proxy access rpc portal (#2107)
* distinct control / data when forward packets
* fix rpc split for udp tunnel
* feat(easytier-web): pass public ip in validate token webhook
* protect rpc port from subnet proxy
2026-04-13 11:03:09 +08:00
Luna Yao ec7ddd3bad fix: filter overlapped proxy cidrs in ProxyCidrsMonitor (#2079)
* feat(route): add async methods to list proxy CIDRs for IPv4 and IPv6
* refactor(ProxyCidrsMonitor): get proxy cidrs from list_proxy_cidrs
2026-04-12 22:18:54 +08:00
Luna Yao 6f3e708679 tunnel(bind): gather all bind logic to a single function (#2070)
* extract a Bindable trait for binding TcpSocket, TcpListener, and UdpSocket
2026-04-12 22:16:58 +08:00
Luna Yao 869e1b89f5 fix: remove log (file) when level is explicitly set to OFF (#2083)
* fix level filter for OFF
* remove unwrap of file appender creation
2026-04-12 22:16:30 +08:00
Luna Yao 9e0a3b6936 ci: rewrite build workflows (#2089) 2026-04-12 22:14:41 +08:00
Luna Yao c6cb1a77d0 chore: clippy fix some code on Windows (#2106) 2026-04-12 22:13:58 +08:00
deddey 83010861ba Optimize network interface configuration for macOS and FreeBSD to avoid hard-coded IP addresses (#1853)
Co-authored-by: KKRainbow <443152178@qq.com>
2026-04-12 21:00:59 +08:00
Luna Yao daa53e5168 log: auto-init log for tests (#2073) 2026-04-12 13:04:21 +08:00
fanyang 51befdbf87 fix(faketcp): harden packet parsing against malformed frames (#2103)
Discard malformed fake TCP frames instead of panicking so OpenWrt
nodes can survive unexpected or truncated packets.

Also emit the correct IPv6 ethertype and cover the parser with
round-trip and truncation regression tests.
2026-04-12 13:02:23 +08:00
Luna Yao 8311b11713 refactor: remove NoGroAsyncUdpSocket (#1867) 2026-04-10 23:22:08 +08:00
Luna Yao 19c80c7b9c cli: do not add offset when port = 0 (#2085) 2026-04-10 23:21:15 +08:00
Luna Yao a879dd1b14 chore: update Rust to 2024 edition (#2066) 2026-04-10 00:22:12 +08:00
Luna Yao a8feb9ac2b chore: use Debug to print errors (#2086) 2026-04-09 09:45:55 +08:00
Luna Yao c5fbd29c0e ci: fix skip condition for draft pull requests in CI workflows (#2088)
* ci: run xxx-result only when pre_job is run successfully
* fix get-result steps
2026-04-09 09:45:04 +08:00
Luna Yao 26b1794723 ci: accecelerate pipeline (#2078)
* enable concurrency

pr

* do not run build on draft PRs

pr

* enable fail-fast for build workflows
2026-04-08 08:43:03 +08:00
Luna Yao 371b4b70a3 proto(utils): add TransientDigest trait (#2071) 2026-04-08 00:06:48 +08:00
Luna Yao b2cc38ee63 chore(clippy): disallow some methods from itertools (#2075) 2026-04-07 16:27:33 +08:00
Luna Yao 79b562cdc9 drop peer_mgr in time (#2064) 2026-04-06 11:31:05 +08:00
fanyang e3f089251c fix(ospf): mitigate route sync storm under connection flapping (#2063)
Addresses issue #2016 where nodes behind unstable networks
(e.g. campus firewalls) cause excessive traffic that can freeze
the remote node.

Two changes in peer_ospf_route.rs:

- Make do_sync_route_info only trigger reverse sync_now when
  incoming data actually changed the route table or foreign
  network state.  The previous unconditional sync_now created
  an A->B->A->B ping-pong cycle on every RPC exchange.

- Add exponential backoff (50ms..5s) to session_task retry loop.
  The previous fixed 50ms retry produced ~20 RPCs/s during
  sustained network instability.
2026-04-06 11:26:20 +08:00
fanyang cf6dcbc054 Fix IPv6 TCP tunnel display formatting (#1980)
Normalize composite tunnel display values before rendering peer and
debug output so IPv6 tunnel types no longer append `6` to the port.

- Preserve prefixes like `txt-` while converting tunnel schemes to
  their IPv6 form.
- Recover malformed values such as `txt-tcp://...:110106` into
  `txt-tcp6://...:11010`.
- Reuse the normalized remote address display in CLI debug output.
2026-04-05 22:12:55 +08:00
fanyang 2cf2b0fcac feat(cli): implement connector add/remove, drop peer stubs (#2058)
Implement the previously stubbed connector add/remove CLI commands
using PatchConfig RPC with InstanceConfigPatch.connectors, and
remove the peer add/remove stubs that had incorrect semantics.
2026-04-05 13:56:17 +08:00
dependabot[bot] aa0cca3bb6 build(deps): bump quinn-proto in /easytier-contrib/easytier-ohrs (#2059)
Bumps [quinn-proto](https://github.com/quinn-rs/quinn) from 0.11.13 to 0.11.14.
- [Release notes](https://github.com/quinn-rs/quinn/releases)
- [Commits](https://github.com/quinn-rs/quinn/compare/quinn-proto-0.11.13...quinn-proto-0.11.14)

---
updated-dependencies:
- dependency-name: quinn-proto
  dependency-version: 0.11.14
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-05 13:16:33 +08:00
KKRainbow fb59f01058 fix: reconcile webhook-managed configs and make disable_p2p more intelligent (#2057)
* reconcile infra configs on webhook validate
* make disable_p2p more intelligent
* fix stats
2026-04-04 23:41:57 +08:00
Luna Yao e91a0da70a refactor: listener/connector protocol abstraction (#2026)
* fix listener protocol detection
* replace IpProtocol with IpNextHeaderProtocol
* use an enum to gather all listener schemes
* rename ListenerScheme to TunnelScheme; replace IpNextHeaderProtocols with socket2::Protocol
* move TunnelScheme to tunnel
* add IpScheme, simplify connector creation
* format; fix some typos; remove check_scheme_...;
* remove PROTO_PORT_OFFSET
* rename WSTunnel.. -> WsTunnel.., DNSTunnel.. -> DnsTunnel..
2026-04-04 10:55:58 +08:00
Luna Yao 9cc617ae4c ci: build rpm package (#2044)
* add rpm to ci
* rename build_filter to build-filter
* use prepare-pnpm action
2026-04-04 10:32:08 +08:00
韩嘉乐 e4b0f1f1bb Rename libeasytier_ohrs.so to libeasytier_release.so when build release package (#2056)
Rename shared library file for release.
2026-04-04 10:29:37 +08:00
Luna Yao 443c3ca0b3 fix: append address of reverse proxy to remote_addr (#2034)
* append address of reverse proxy to remote_addr
* validate proxy address in test
2026-03-30 16:48:23 +08:00
Luna Yao 55a0e5952c chore: use cfg_aliases for mobile (#2033) 2026-03-30 16:38:39 +08:00
KKRainbow 1dff388717 bump version to v2.6.0 (#2039) 2026-03-30 15:50:07 +08:00
Luna Yao 61c741f887 add BoxExt trait (#2036) 2026-03-30 13:25:53 +08:00
ParkGarden 01dd9a05c3 fix: 重构了 Magisk 模块的 easytier_core.sh, action.sh, uninstall.sh 三个脚本的逻辑,优化参数解析与进程管理,调整措辞 (#1964) 2026-03-30 13:18:42 +08:00
KKRainbow 8c19a2293c fix(windows): avoid pnet interface enumeration panic (#2031) 2026-03-29 23:16:44 +08:00
KKRainbow a1bec48dc9 fix android vpn permission grant (#2023)
* fix android vpn permission grant
* fix url input behaviour
2026-03-29 23:16:32 +08:00
KKRainbow 7e289865b2 fix(faketcp): avoid pnet interface lookup on windows (#2029) 2026-03-29 19:26:29 +08:00
fanyang 742c7edd57 fix: use default connection loss rate for peer stats (#2030) 2026-03-29 19:25:25 +08:00
Luna Yao b71a2889ef suppress clippy warnings when no feature flags are enabled (#2028) 2026-03-29 11:02:23 +08:00
KKRainbow bcd75d6ce3 Add instance recv limiter in peer conn (#2027) 2026-03-29 10:28:02 +08:00
Luna Yao d4c1b0e867 fix: read X-Forwarded-For from HTTP header of WS/WSS (#2019) 2026-03-28 22:20:46 +08:00
KKRainbow b037ea9c3f Relax private mode foreign network secret checks (#2022) 2026-03-28 22:19:23 +08:00
Luna Yao b5f475cd4c filter overlapped proxy cidr (#2024) 2026-03-28 09:40:05 +08:00
Luna Yao eaa4d2c7b8 test: use taiki-e/install-action for cargo-hack (#2020) 2026-03-28 00:07:59 +08:00
Luna Yao e160d9b048 ci: remove aes-gcm from check (#1925) 2026-03-27 22:48:22 +08:00
KKRainbow 0aeea39fbe refactor(gui): collapse public server and standalone into initial peer list (#2017)
The GUI exposed three networking modes: public server, manual, and standalone. In practice EasyTier does not have a server/client role distinction here. Those options only mapped to different peer bootstrap shapes, which made the product model misleading and pushed users toward a non-existent "public server" concept.

This change rewrites the shared configuration UX around initial nodes. Users now add or remove one or more initial node URLs directly, and the UI explains that EasyTier networking works like plugging in a cable: once a node connects to one or more existing nodes, it can join the mesh. Initial nodes may be self-hosted or shared by others.

To preserve compatibility, the frontend keeps the legacy fields and adds normalization helpers in the shared NetworkConfig layer. Old configs are read as initial_node_urls, while saves, runs, validation, config generation, and persisted GUI config sync still denormalize back into the current backend shape: zero initial nodes -> Standalone, one -> PublicServer, many -> Manual. This avoids any proto or backend API change while making old saved configs and imported TOML files load cleanly in the new UI.

Code changes:

- add initial_node_urls plus normalize/denormalize helpers in the shared frontend NetworkConfig model

- remove the mode switch and public-server/manual specific inputs from the shared Config component and replace them with a single initial-node list plus explanatory copy

- update Chinese and English locale strings for the new terminology

- normalize configs received from GUI/web backends and denormalize them before outbound API calls

- normalize GUI save-config events before storing them in localStorage so legacy payloads remain editable under the new model
2026-03-27 11:37:09 +08:00
KKRainbow e000636d83 feat(stats): add by-instance traffic metrics (#2011) 2026-03-26 13:46:33 +08:00
Luna Yao 8e4dc508bb test: improve test_txt_public_stun_server with timeout and retry mechanism (#2014) 2026-03-26 09:32:07 +08:00
Luna Yao e2684a93de refactor: use strum on EncryptionAlgorithm, use Xor as default when AesGcm not available (#1923) 2026-03-25 18:42:34 +08:00
KKRainbow 1d89ddbb16 Add lazy P2P demand tracking and need_p2p override (#2003)
- add lazy_p2p so nodes only start background P2P for peers that actually have recent business traffic
- add need_p2p so specific peers can still request eager background P2P even when other nodes enable lazy mode
- cover the new behavior with focused connector/peer-manager tests plus three-node integration tests that verify relay-to-direct route transition
2026-03-23 09:38:57 +08:00
KKRainbow 2bfdd44759 multi_fix: harden peer/session handling, tighten foreign-network trust, and improve web client metadata (#1999)
* machine-id should be scoped unbder same user-id
* feat: report device os metadata to console
* fix sync root key cause packet loss
* fix tun packet not invalid
* fix faketcp cause lat jitter
* fix some packet not decrypt
* fix peer info patch, improve performance of update self info
* fix foreign credential identity mismatch handling
2026-03-21 21:06:07 +08:00
Luna Yao 77966916c4 cargo: add used features for windows-sys (#1924) 2026-03-17 14:10:50 +08:00
TsXor 26b7455c1e ignores eol difference for auto-generated files (#1997) 2026-03-16 23:40:38 +08:00
KKRainbow 8922e7b991 fix: foreign credential handling and trusted key visibility (#1993)
* fix foreign credential handling
* allow list foreign network trusted keys
* fix(gui): delete removed config-server networks
* fix(web): reset managed instances on first sync
2026-03-16 22:19:31 +08:00
KKRainbow e6ac31fb20 feat(web): add webhook-managed machine access and multi-instance CLI support (#1989)
* feat: add webhook-managed access and multi-instance CLI support
* fix(foreign): verify credential of foreign credential peer
2026-03-15 12:08:50 +08:00
KKRainbow c8f3c5d6aa feat(credential): support custom credential ID generation (#1984)
introduces support for custom credential ID generation, allowing users to specify their own credential IDs instead of relying solely on auto-generated UUIDs.
2026-03-12 00:48:24 +08:00
KKRainbow 330659e449 feat(web): full-power RPC access + typed JSON proxy endpoint (#1983)
- extend web controller bindings to cover full RPC service set
- update rpc_service API wiring and session/controller integration
- generate trait-level json_call_method in rpc codegen
- route restful proxy-rpc requests via scoped typed clients
- add json-call regression tests and required Sync bound fixes~
2026-03-11 20:32:37 +08:00
Maxwell 80043df292 script: introduce EasyTier powershell installer (#1975) 2026-03-11 11:57:03 +08:00
KKRainbow ecd1ea6f8c feat(web): implement secure core-web tunnel with Noise protocol (#1976)
Implement end-to-end encryption for core-web connections using the
Noise protocol framework with the following changes:

Client-side (easytier/src/web_client/):
- Add security.rs module with Noise handshake implementation
- Add upgrade_client_tunnel() for client-side handshake
- Add Noise frame encryption/decryption via TunnelFilter
- Integrate GetFeature RPC for capability negotiation
- Support secure_mode option to enforce encrypted connections
- Handle graceful fallback for backward compatibility

Server-side (easytier-web/):
- Accept Noise handshake in client_manager
- Expose encryption support via GetFeature RPC

The implementation uses Noise_NN_25519_ChaChaPoly_SHA256 pattern for
encryption without authentication. Provides backward compatibility
with automatic fallback to plaintext connections.
2026-03-10 08:48:08 +08:00
KKRainbow 694b8d349d feat(credential): enforce signed credential distribution across mixed admin/shared topology (#1972) 2026-03-10 08:37:33 +08:00
KKRainbow ef44027f57 feat(credential): improve credential peer routing and visibility (#1971)
- improve credential peer filtering and related route lookup behavior
- expose credential peer information through CLI and API definitions
- add and refine tests for credential routing and peer interactions
2026-03-08 14:06:33 +08:00
KKRainbow f3db348b01 fix: resolve slow exit and reduce test timeouts (#1970)
- Explicitly shutdown tokio runtime on launcher cleanup to fix slow exit
- Add timeout to tunnel connector in tests to prevent hanging
- Reduce test wait durations from 5s to 100ms for faster test execution
- Bump num-bigint-dig from 0.8.4 to 0.8.6
2026-03-08 12:27:42 +08:00
KKRainbow c4eacf4591 feat(credential): implement credential peer auth and trust propagation (#1968)
- add credential manager and RPC/CLI for generate/list/revoke
- support credential-based Noise authentication and revocation handling
- propagate trusted credential metadata through OSPF route sync
- classify direct peers by auth level in session maintenance
- normalize sender credential flag for legacy non-secure compatibility
- add unit/integration tests for credential join, relay and revocation
2026-03-07 22:58:15 +08:00
KKRainbow 59d4475743 feat: relay peer end-to-end encryption via Noise IK handshake (#1960)
Enable encryption for non-direct nodes requiring relay forwarding.
When secure_mode is enabled, peers perform Noise IK handshake to
establish an encrypted PeerSession. Relay packets are encrypted at
the sender and decrypted at the receiver. Intermediate forwarding
nodes cannot read plaintext data.

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: KKRainbow <5665404+KKRainbow@users.noreply.github.com>
2026-03-07 14:47:22 +08:00
KKRainbow 22b4c4be2c fix: guard macos-ne feature with target_os = "macos" in cfg expressions (#1962)
All 13 occurrences of `any(target_os = "ios", feature = "macos-ne")` are
replaced with `any(target_os = "ios", all(target_os = "macos", feature = "macos-ne"))`.

Previously, enabling `macos-ne` on non-macOS platforms (e.g. `--all-features`
on Linux) would incorrectly compile macOS/mobile-specific code paths, causing
build failures or wrong runtime behavior.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-05 00:06:21 +08:00
Luna Yao 5f31583a84 refactor: 使用 tracing 输出日志 (#1856)
* change all println to tracing
2026-03-04 09:52:23 +08:00
Mg Pig 1d25240d8c refactor(ui): extract URL input components and enhance UI responsiveness (#1819) 2026-03-04 09:49:15 +08:00
fanyang eeb507d6ea fix: register PeerCenterRpc in management API server so CLI peer-center works (#1929)
PeerCenterRpc was only registered in the per-instance peer-to-peer RPC
manager (domain = network_name), but not in the management API server
(domain = ""). The CLI connects to the management API with an empty
domain, causing "Invalid service name: PeerCenterRpc" errors.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-04 09:37:37 +08:00
fanyang 9e9916efa5 fix(connector): skip self-connection when peer shares local interface IPs (#1941)
When two EasyTier instances run on the same machine and share the same
network, the direct connector would expand a remote peer's 0.0.0.0
listener into local interface IPs and then attempt to connect to
itself, causing an infinite loop of failed connection attempts.

The existing `peer_id != my_peer_id` guard does not cover this case
because the two instances have different peer IDs despite sharing the
same physical network interfaces.

Fix by adding a self-connection check in `spawn_direct_connect_task`:
before spawning a connect task, compare the candidate (scheme, IP,
port) against the local running listeners. If a local listener matches
on all three dimensions — accounting for 0.0.0.0/:: wildcards by
checking membership in the local interface IP sets — the candidate is
silently dropped with a DEBUG log message.

The fix covers all four code paths:
- IPv4 unspecified (0.0.0.0) expansion loop
- IPv4 specific-address branch
- IPv6 unspecified (::) expansion loop
- IPv6 specific-address branch

The TESTING flag logic is untouched so existing unit tests are
unaffected.

* refactor(connector): replace is_self_connect closure with GlobalCtx::should_deny_proxy (#1954)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
2026-03-04 09:36:35 +08:00
hello db6b9e3684 feat: core config server use last path segment as user name (#1931) 2026-03-03 18:24:28 +08:00
Mg Pig ff24332e23 feat(web): add OIDC SSO login support (#1943) 2026-03-03 18:23:31 +08:00
fanyang d4ff0b1767 build(deps): upgrade vite to 5.4.21 in frontend and gui packages (#1950) 2026-03-01 13:47:02 +08:00
Mg Pig 5716f7f16b fix(web): allow configuring listen address for API and web servers (#1919) (#1948) 2026-03-01 01:02:31 +08:00
fanyang e5bd8f9e24 build(deps): upgrade minimatch to 10.2.4 (#1949) 2026-02-28 22:40:47 +08:00
sky96111 b56bcfb4b0 fix: increase websocket peer connection timeout to 20 seconds (#1939)
- Add ws/wss protocols to long timeout list
2026-02-28 18:26:19 +08:00
fanyang fb95b4827c build(deps): bump axios from 1.11.0 to 1.13.6 in frontend packages (#1947)
Addresses security vulnerabilities in axios <1.13.5. Updates the
declared specifier to ^1.13.5 in all three frontend package.json
files and regenerates both npm and pnpm lock files (resolved: 1.13.6).

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 11:17:18 +08:00
fanyang a8f7226195 fix(foreign_network): set avoid_relay_data when relay_data is false (#1935) 2026-02-25 09:30:24 +08:00
dependabot[bot] e6ee485352 build(deps-dev): bump vite from 5.4.10 to 5.4.21 in /easytier-web/frontend-lib (#1922)
* build(deps-dev): bump vite in /easytier-web/frontend-lib

Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.10 to 5.4.21.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.21/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.21/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 5.4.21
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-23 22:47:29 +08:00
hello 73291a3a1c feat: Update Cargo.toml to add support for tls1.2 when use wss (#1917) 2026-02-20 18:01:21 +08:00
fanyang f737708f45 fix: avoid panic on malformed short tunnel packets (#1904) 2026-02-18 00:04:30 +08:00
fanyang aa24d09aa2 fix: replace stale magic DNS records on IP change (#1906)
Magic DNS updates are full snapshots, so appending routes keeps old IPs and returns duplicate A records. Replace each client's previous routes on update and add a regression test to ensure hostname resolution keeps only the latest IP.
2026-02-16 13:20:11 +08:00
fanyang fe4e77979d fix: avoid panic for quic peer urls using port 0 (#1905)
Prevent crashes when users input quic://...:0 by rejecting port 0 explicitly and propagating connect setup errors. Add a regression test to ensure invalid QUIC targets fail gracefully.

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-14 17:10:29 +08:00
Chenx Dust 7a26640c26 feat: support macOS Network Extension (#1902)
* feat: support macOS Network Extension
* fix: disable macOS NE feature in cargo hack check
2026-02-14 14:54:36 +08:00
Mg Pig 5a777959e3 ui: clarify encryption checkbox description in locales (#1841) 2026-02-13 16:04:26 +08:00
Mg Pig 3512a80597 feat(web): add --disable-registration flag to disable user registration (#1881) 2026-02-13 16:03:11 +08:00
Zkitefly 011770a601 Update http_connector.rs (#1900) 2026-02-13 16:02:32 +08:00
Chenx Dust 6475724d2e fix: toggle_window_visibility with focus check (#1888)
* refactor: better logics for toggle_window_visibility
2026-02-11 16:50:36 +08:00
Mg Pig 85e9029577 feat: add Nix CI workflow and update flake.lock dependencies (#1872) 2026-02-10 18:11:35 +08:00
Luna Yao b6e292cce3 ci: use shared key for build workflow (#1868) 2026-02-04 09:48:55 +08:00
KKRainbow c58140fb47 update rust to 1.93 (#1865) 2026-02-04 09:48:43 +08:00
Luna Yao aebb7facfa drop permit reserved by poll_reserve (#1858) 2026-02-03 11:14:11 +08:00
Chenx Dust 1e2124cb99 fix: force set tun fd when received (#1860) 2026-02-03 11:13:31 +08:00
Chenx Dust e1cbd07d1f feat: separate zstd and faketcp into features (#1861)
* feat: separate faketcp into a feature
* fix: no need to initialize out_len
* feat: separate zstd into a feature
* clippy: remove unnecessary cast, because for unix size_t always equals usize
2026-02-03 11:12:33 +08:00
韩嘉乐 7750e81168 CI(ohos): add a condition to check for the publish code (#1863)
Added a condition to check for the presence of a release code when running the publish step
2026-02-03 11:11:45 +08:00
KKRainbow bf3edbd28f remove src modified flag from pm hdr (#1857) 2026-02-02 16:47:26 +08:00
Luna Yao cd2cf56358 refactor: handle quic proxy internally instead of use external udp port (#1743)
* deprecate quic_listen_port, add disable_relay_quic and enable_relay_foreign_network_quic
* add set_src_modified to TcpProxyForWrappedSrcTrait
* prioritize quic over kcp
2026-02-02 11:53:40 +08:00
KKRainbow 21f4a944a7 fix perf degraded because of impact of is_empty() of dashmap (#1854) 2026-02-01 08:51:18 +08:00
KKRainbow 9617005136 make udp->ring transmit reliable (#1851) 2026-01-31 17:23:45 +08:00
deddey c85d1d41b3 allow set TUN dev name on FreeBSD (#1823)
Also rename stale interfaces from previous runs before creating new ones.
Works around rust-tun reusing existing tun0 instead of configured name.

Tested on FreeBSD 14.1
2026-01-30 23:51:52 +08:00
KKRainbow 9e3c9228bb improve perf of remove_network in foreign net mgr (#1847) 2026-01-30 23:04:31 +08:00
Luna Yao acd7c85ff6 ci: speed up test with matrix (#1830)
* add an action to install pnpm packages
* add an action to prepare build environment
* rewrite test workflow, using composite actions and matrix
2026-01-30 22:21:27 +08:00
KKRainbow 8727221513 call remove_peer instead of remove_network when peer id not match (#1844) 2026-01-30 16:01:52 +08:00
Luna Yao cdedaf3f63 refactor(quic): remove quinn encryption (#1831)
* use quinn-plaintext
* remove server_cert in QUICTunnelListener
* remove some customized transport config
* leave max_concurrent_bidi_streams as default

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-30 10:21:59 +08:00
KKRainbow ffe5644ddc add token bucket limiter on peer conn recv (#1842)
We should limit peer conn recv to make sure we don't recv too much from peers.
2026-01-29 16:12:26 +08:00
Chenx Dust ccc684a9ab Fix: Fixed compilation issue after partially removing the feature flag (#1835) 2026-01-28 21:38:34 +08:00
fanyang 977e502150 feat(cli): add column truncation controls (#1838)
- drop low-priority columns when tables exceed terminal width
- truncate optional columns to fit remaining width
- add --no-trunc flag to disable truncation
- compute column widths using unicode display width

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-28 14:50:14 +08:00
Mg Pig 518d26b25f feat: add X-Network-Name header to HTTP connector requests (#1839)
This allows HTTP redirect servers to provide network-specific node
lists based on the client's network identity. Updated unit tests
to verify the header is correctly sent.
2026-01-28 14:48:45 +08:00
KKRainbow 101f416268 Introduce secure mode (part 1) (#1808)
Use noise protocol on handshake. Check peer's public key if needed. Also support rekey and replay attack prevention.

E2EE and temporary password will be implemented based on this.
2026-01-25 20:16:51 +08:00
Chenx Dust ffa08d1c43 feat: add peer_id in MyNodeInfo (#1821) 2026-01-22 22:44:37 +08:00
韩嘉乐 cf3f9169b7 CI(ohos): Enhance CI workflow for release package builds (#1812)
Added support for building and publishing release packages based on tags.
2026-01-20 12:25:10 +08:00
KKRainbow 8343cd5e76 fix config loss when run network (#1802) 2026-01-17 00:58:42 +08:00
KKRainbow 005b321f62 allow open rpc port in gui normal mode (#1795)
* allow open rpc port for gui normal mode
* downgrade dev tool console
2026-01-16 11:12:32 +08:00
KKRainbow 53264f67bf fix peer establish direct conn with subnet proxy to one of local interface (#1782)
* fix peer establish direct conn with subnet proxy to one of local interface

* fix peer mgr ref loop
2026-01-15 01:00:32 +08:00
韩嘉乐 f8b34e3c86 Merge pull request #1787 from EasyTier/FrankHan052176-patch-1
action[ohos] fix the cnt of commit in ohos.yml
2026-01-13 23:58:26 +08:00
韩嘉乐 ce1bdac2bc action[ohos] fix the cnt of commit in ohos.yml 2026-01-13 22:57:43 +08:00
Copilot bd8f01fb26 Add Nushell completion script generation support (#1756)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-01-11 18:41:02 +08:00
Chenx Dust b590700540 feat: support unix socket tunnel (for ios) (#1779)
Co-authored-by: Page Chen <pagechen04@gmail.com>
2026-01-11 16:37:32 +08:00
Chenx Dust 48c5c23f9b feat: support compile for iOS (#1777) 2026-01-11 16:36:58 +08:00
朝倉水希 f4f591d14c fix: outbound packet not dropped by acl (#1766) 2026-01-08 19:58:23 +08:00
Mg Pig 0c16e2211b feat(gui): persist and restore last used network instance ID (#1762) 2026-01-08 17:03:51 +08:00
Rinne 4bfea06a12 docs: update locales (#1755)
Co-authored-by: KKRainbow <443152178@qq.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-08 11:08:32 +08:00
桜井 ホタル 057ee9f2c5 Resolves the issue of DNS resolution failure after installing KSU modules, resulting in inability to connect to nodes. (#1761) 2026-01-08 11:07:52 +08:00
Burning_TNT 7f48ca54a3 Implement requesting tun_fd with tokio channel. (#1734)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-04 21:04:43 +08:00
hello ee5227130c feat: Update Cargo.toml for easytier-gui and android app to support tls1.2 (#1744) 2026-01-04 21:03:34 +08:00
韩嘉乐 2e0d9a2b54 Refactor EasyTier version resolution in workflow (#1747)
Updated the workflow to resolve the EasyTier version based on the latest commit and tag information.
2026-01-04 21:02:55 +08:00
编程小白 c5d732773f Convert dead URL to ASCII before socket address lookup (#1739) 2026-01-02 18:49:23 +08:00
304 changed files with 51583 additions and 9403 deletions
+35 -54
View File
@@ -1,29 +1,40 @@
[target.x86_64-unknown-linux-musl]
linker = "rust-lld"
rustflags = ["-C", "linker-flavor=ld.lld"]
# region Native
[target.x86_64-unknown-linux-gnu]
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
[target.aarch64-unknown-linux-ohos]
ar = "/usr/local/ohos-sdk/linux/native/llvm/bin/llvm-ar"
linker = "/home/runner/sdk/native/llvm/aarch64-unknown-linux-ohos-clang.sh"
[target.'cfg(all(windows, target_env = "msvc"))']
rustflags = ["-C", "target-feature=+crt-static"]
[target.aarch64-unknown-linux-ohos.env]
PKG_CONFIG_PATH = "/usr/local/ohos-sdk/linux/native/sysroot/usr/lib/pkgconfig:/usr/local/ohos-sdk/linux/native/sysroot/usr/local/lib/pkgconfig"
PKG_CONFIG_LIBDIR = "/usr/local/ohos-sdk/linux/native/sysroot/usr/lib:/usr/local/ohos-sdk/linux/native/sysroot/usr/local/lib"
PKG_CONFIG_SYSROOT_DIR = "/usr/local/ohos-sdk/linux/native/sysroot"
SYSROOT = "/usr/local/ohos-sdk/linux/native/sysroot"
# region
# region CI
[target.x86_64-unknown-linux-musl]
rustflags = ["-C", "target-feature=+crt-static"]
[target.aarch64-unknown-linux-musl]
linker = "aarch64-unknown-linux-musl-gcc"
rustflags = ["-C", "target-feature=+crt-static"]
[target.riscv64gc-unknown-linux-musl]
linker = "riscv64-unknown-linux-musl-gcc"
rustflags = ["-C", "target-feature=+crt-static"]
[target.'cfg(all(windows, target_env = "msvc"))']
[target.armv7-unknown-linux-musleabihf]
rustflags = ["-C", "target-feature=+crt-static"]
[target.armv7-unknown-linux-musleabi]
rustflags = ["-C", "target-feature=+crt-static"]
[target.arm-unknown-linux-musleabihf]
rustflags = ["-C", "target-feature=+crt-static"]
[target.arm-unknown-linux-musleabi]
rustflags = ["-C", "target-feature=+crt-static"]
[target.loongarch64-unknown-linux-musl]
rustflags = ["-C", "target-feature=+crt-static"]
[target.mipsel-unknown-linux-musl]
@@ -64,44 +75,14 @@ rustflags = [
"gcc",
]
[target.armv7-unknown-linux-musleabihf]
linker = "armv7-unknown-linux-musleabihf-gcc"
rustflags = ["-C", "target-feature=+crt-static"]
[target.aarch64-unknown-linux-ohos]
ar = "/usr/local/ohos-sdk/linux/native/llvm/bin/llvm-ar"
linker = "/home/runner/sdk/native/llvm/aarch64-unknown-linux-ohos-clang.sh"
[target.armv7-unknown-linux-musleabi]
linker = "armv7-unknown-linux-musleabi-gcc"
rustflags = ["-C", "target-feature=+crt-static"]
[target.aarch64-unknown-linux-ohos.env]
PKG_CONFIG_PATH = "/usr/local/ohos-sdk/linux/native/sysroot/usr/lib/pkgconfig:/usr/local/ohos-sdk/linux/native/sysroot/usr/local/lib/pkgconfig"
PKG_CONFIG_LIBDIR = "/usr/local/ohos-sdk/linux/native/sysroot/usr/lib:/usr/local/ohos-sdk/linux/native/sysroot/usr/local/lib"
PKG_CONFIG_SYSROOT_DIR = "/usr/local/ohos-sdk/linux/native/sysroot"
SYSROOT = "/usr/local/ohos-sdk/linux/native/sysroot"
[target.loongarch64-unknown-linux-musl]
linker = "loongarch64-unknown-linux-musl-gcc"
rustflags = ["-C", "target-feature=+crt-static"]
[target.arm-unknown-linux-musleabihf]
linker = "arm-unknown-linux-musleabihf-gcc"
rustflags = [
"-C",
"target-feature=+crt-static",
"-L",
"./musl_gcc/arm-unknown-linux-musleabihf/arm-unknown-linux-musleabihf/lib",
"-L",
"./musl_gcc/arm-unknown-linux-musleabihf/lib/gcc/arm-unknown-linux-musleabihf/15.1.0",
"-l",
"atomic",
"-l",
"gcc",
]
[target.arm-unknown-linux-musleabi]
linker = "arm-unknown-linux-musleabi-gcc"
rustflags = [
"-C",
"target-feature=+crt-static",
"-L",
"./musl_gcc/arm-unknown-linux-musleabi/arm-unknown-linux-musleabi/lib",
"-L",
"./musl_gcc/arm-unknown-linux-musleabi/lib/gcc/arm-unknown-linux-musleabi/15.1.0",
"-l",
"atomic",
"-l",
"gcc",
]
# endregion
+90
View File
@@ -0,0 +1,90 @@
name: prepare-build
author: Luna
description: Prepare build environment
inputs:
target:
description: 'The target to build for'
required: false
pnpm:
description: 'Whether to run pnpm build'
required: true
default: 'true'
pnpm-build-filter:
description: 'The filter argument for pnpm build (e.g. ./easytier-web/*)'
required: false
default: './easytier-web/*'
gui:
description: 'Whether to prepare the GUI build environment'
required: true
default: 'true'
token:
description: 'GitHub token, used by setup-protoc action'
required: false
runs:
using: 'composite'
steps:
- run: mkdir -p easytier-gui/dist
shell: bash
- name: Install dependencies
if: ${{ runner.os == 'Linux' }}
run: |
sudo apt-get update
sudo apt-get install -qqy build-essential mold musl-tools
shell: bash
- name: Setup Frontend Environment
if: ${{ inputs.pnpm == 'true' }}
uses: ./.github/actions/prepare-pnpm
with:
build-filter: ${{ inputs.pnpm-build-filter }}
- name: Install GUI dependencies (Linux)
if: ${{ inputs.gui == 'true' && runner.os == 'Linux' }}
run: |
sudo apt-get install -qq xdg-utils \
libappindicator3-dev \
libgtk-3-dev \
librsvg2-dev \
libwebkit2gtk-4.1-dev \
libxdo-dev
shell: bash
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: 1.95
target: ${{ !contains(inputs.target, 'mips') && inputs.target || '' }}
components: ${{ contains(inputs.target, 'mips') && 'rust-src' || '' }}
cache: false
rustflags: ''
- name: Install Rust (MIPS)
if: ${{ contains(inputs.target, 'mips') }}
run: |
MUSL_TARGET=${{ inputs.target }}sf
mkdir -p ./musl_gcc
wget --inet4-only -c https://github.com/cross-tools/musl-cross/releases/download/20250520/${MUSL_TARGET}.tar.xz -P ./musl_gcc/
tar xf ./musl_gcc/${MUSL_TARGET}.tar.xz -C ./musl_gcc/
sudo ln -sf $(pwd)/musl_gcc/${MUSL_TARGET}/bin/*gcc /usr/bin/
sudo ln -sf $(pwd)/musl_gcc/${MUSL_TARGET}/include/ /usr/include/musl-cross
sudo ln -sf $(pwd)/musl_gcc/${MUSL_TARGET}/${MUSL_TARGET}/sysroot/ ./musl_gcc/sysroot
sudo chmod -R a+rwx ./musl_gcc
if [[ -d "./musl_gcc/sysroot" ]]; then
echo "BINDGEN_EXTRA_CLANG_ARGS=--sysroot=$(readlink -f ./musl_gcc/sysroot)" >> $GITHUB_ENV
fi
cd "$PWD/musl_gcc/${MUSL_TARGET}/lib/gcc/${MUSL_TARGET}/15.1.0" || exit 255
# for panic-abort
cp libgcc_eh.a libunwind.a
# for mimalloc
ar x libgcc.a _ctzsi2.o _clz.o _bswapsi2.o
ar rcs libctz.a _ctzsi2.o _clz.o _bswapsi2.o
shell: bash
- name: Setup protoc
uses: arduino/setup-protoc@v3
with:
# GitHub repo token to use to avoid rate limiter
repo-token: ${{ inputs.token }}
+48
View File
@@ -0,0 +1,48 @@
name: 'Setup pnpm'
author: Luna
description: 'Setup Node.js, pnpm, and install dependencies'
inputs:
build-filter:
description: 'The filter argument for pnpm build (e.g. ./easytier-web/*)'
required: false
default: ''
runs:
using: "composite"
steps:
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: 22
- name: Install pnpm
uses: pnpm/action-setup@v5
with:
version: 10
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v5
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install and build
shell: bash
run: |
pnpm -r install
if [ -n "${{ inputs.build-filter }}" ]; then
echo "Building with filter: ${{ inputs.build-filter }}"
pnpm -r --filter "${{ inputs.build-filter }}" build
else
echo "No build filter provided, building all packages"
pnpm -r build
fi
+143 -177
View File
@@ -2,9 +2,14 @@ name: EasyTier Core
on:
push:
branches: ["develop", "main", "releases/**"]
branches: [ "develop", "main", "releases/**" ]
pull_request:
branches: ["develop", "main"]
branches: [ "develop", "main" ]
types: [ opened, synchronize, reopened, ready_for_review ]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
@@ -18,6 +23,7 @@ jobs:
pre_job:
# continue-on-error: true # Uncomment once integration is finished
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
# Map a step output to a job output
outputs:
# do not skip push on branch starts with releases/
@@ -30,85 +36,69 @@ jobs:
concurrent_skipping: 'same_content_newer'
skip_after_successful_duplicate: 'true'
cancel_others: 'true'
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", ".github/workflows/core.yml", ".github/workflows/install_rust.sh"]'
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", ".github/workflows/core.yml", ".github/actions/**", "easytier-web/**"]'
build_web:
runs-on: ubuntu-latest
needs: pre_job
if: needs.pre_job.outputs.should_skip != 'true'
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v5
- uses: actions/setup-node@v4
- name: Setup Frontend Environment
uses: ./.github/actions/prepare-pnpm
with:
node-version: 22
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install frontend dependencies
run: |
pnpm -r install
pnpm -r --filter "./easytier-web/*" build
build-filter: './easytier-web/*'
- name: Archive artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: easytier-web-dashboard
path: |
easytier-web/frontend/dist/*
build:
strategy:
fail-fast: false
fail-fast: true
matrix:
include:
- TARGET: aarch64-unknown-linux-musl
OS: ubuntu-22.04
ARTIFACT_NAME: linux-aarch64
- TARGET: x86_64-unknown-linux-musl
OS: ubuntu-22.04
OS: ubuntu-24.04
ARTIFACT_NAME: linux-x86_64
- TARGET: riscv64gc-unknown-linux-musl
OS: ubuntu-22.04
ARTIFACT_NAME: linux-riscv64
- TARGET: mips-unknown-linux-musl
OS: ubuntu-22.04
ARTIFACT_NAME: linux-mips
- TARGET: mipsel-unknown-linux-musl
OS: ubuntu-22.04
ARTIFACT_NAME: linux-mipsel
- TARGET: armv7-unknown-linux-musleabihf # raspberry pi 2-3-4, not tested
OS: ubuntu-22.04
ARTIFACT_NAME: linux-armv7hf
- TARGET: armv7-unknown-linux-musleabi # raspberry pi 2-3-4, not tested
OS: ubuntu-22.04
ARTIFACT_NAME: linux-armv7
- TARGET: arm-unknown-linux-musleabihf # raspberry pi 0-1, not tested
OS: ubuntu-22.04
ARTIFACT_NAME: linux-armhf
- TARGET: arm-unknown-linux-musleabi # raspberry pi 0-1, not tested
OS: ubuntu-22.04
ARTIFACT_NAME: linux-arm
- TARGET: aarch64-unknown-linux-musl
OS: ubuntu-24.04-arm
ARTIFACT_NAME: linux-aarch64
- TARGET: riscv64gc-unknown-linux-musl
OS: ubuntu-24.04
ARTIFACT_NAME: linux-riscv64
- TARGET: loongarch64-unknown-linux-musl
OS: ubuntu-24.04
ARTIFACT_NAME: linux-loongarch64
- TARGET: armv7-unknown-linux-musleabihf # raspberry pi 2-3-4, not tested
OS: ubuntu-24.04
ARTIFACT_NAME: linux-armv7hf
- TARGET: armv7-unknown-linux-musleabi # raspberry pi 2-3-4, not tested
OS: ubuntu-24.04
ARTIFACT_NAME: linux-armv7
- TARGET: arm-unknown-linux-musleabihf # raspberry pi 0-1, not tested
OS: ubuntu-24.04
ARTIFACT_NAME: linux-armhf
- TARGET: arm-unknown-linux-musleabi # raspberry pi 0-1, not tested
OS: ubuntu-24.04
ARTIFACT_NAME: linux-arm
- TARGET: mips-unknown-linux-musl
OS: ubuntu-24.04
ARTIFACT_NAME: linux-mips
- TARGET: mipsel-unknown-linux-musl
OS: ubuntu-24.04
ARTIFACT_NAME: linux-mipsel
- TARGET: x86_64-unknown-freebsd
OS: ubuntu-24.04
ARTIFACT_NAME: freebsd-13.2-x86_64
BSD_VERSION: 13.2
- TARGET: x86_64-apple-darwin
OS: macos-latest
ARTIFACT_NAME: macos-x86_64
@@ -119,17 +109,12 @@ jobs:
- TARGET: x86_64-pc-windows-msvc
OS: windows-latest
ARTIFACT_NAME: windows-x86_64
- TARGET: aarch64-pc-windows-msvc
OS: windows-latest
ARTIFACT_NAME: windows-arm64
- TARGET: i686-pc-windows-msvc
OS: windows-latest
ARTIFACT_NAME: windows-i686
- TARGET: x86_64-unknown-freebsd
OS: ubuntu-22.04
ARTIFACT_NAME: freebsd-13.2-x86_64
BSD_VERSION: 13.2
- TARGET: aarch64-pc-windows-msvc
OS: windows-11-arm
ARTIFACT_NAME: windows-arm64
runs-on: ${{ matrix.OS }}
env:
@@ -142,7 +127,7 @@ jobs:
- build_web
if: needs.pre_job.outputs.should_skip != 'true'
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v5
- name: Set current ref as env variable
run: |
@@ -154,158 +139,131 @@ jobs:
name: easytier-web-dashboard
path: easytier-web/frontend/dist/
- name: Prepare build environment
uses: ./.github/actions/prepare-build
with:
target: ${{ matrix.TARGET }}
gui: true
pnpm: true
token: ${{ secrets.GITHUB_TOKEN }}
- uses: Swatinem/rust-cache@v2
if: ${{ ! endsWith(matrix.TARGET, 'freebsd') }}
with:
# The prefix cache key, this can be changed to start a new cache manually.
# default: "v0-rust"
prefix-key: ""
shared-key: "core-registry"
cache-targets: "false"
- name: Setup protoc
uses: arduino/setup-protoc@v3
- uses: mlugg/setup-zig@v2
if: ${{ contains(matrix.OS, 'ubuntu') }}
with:
# GitHub repo token to use to avoid rate limiter
repo-token: ${{ secrets.GITHUB_TOKEN }}
version: 0.16.0
use-cache: true
- name: Build Core & Cli
if: ${{ ! endsWith(matrix.TARGET, 'freebsd') }}
run: |
bash ./.github/workflows/install_rust.sh
- uses: taiki-e/install-action@v2
if: ${{ contains(matrix.OS, 'ubuntu') }}
with:
tool: cargo-zigbuild
# loongarch need llvm-18
if [[ $TARGET =~ ^loongarch.*$ ]]; then
sudo apt-get install -qq llvm-18 clang-18
export LLVM_CONFIG_PATH=/usr/lib/llvm-18/bin/llvm-config
fi
# we set the sysroot when sysroot is a dir
# this dir is a soft link generated by install_rust.sh
# kcp-sys need this to gen ffi bindings. without this clang may fail to find some libc headers such as bits/libc-header-start.h
if [[ -d "./musl_gcc/sysroot" ]]; then
export BINDGEN_EXTRA_CLANG_ARGS=--sysroot=$(readlink -f ./musl_gcc/sysroot)
fi
if [[ $OS =~ ^ubuntu.*$ && $TARGET =~ ^mips.*$ ]]; then
cargo +nightly-2025-09-01 build -r --target $TARGET -Z build-std=std,panic_abort --package=easytier --features=jemalloc
- name: Build
if: ${{ !contains(matrix.TARGET, 'mips') }}
run: |
if [[ "$TARGET" == *windows* ]]; then
SUFFIX=.exe
else
if [[ $OS =~ ^windows.*$ ]]; then
SUFFIX=.exe
CORE_FEATURES="--features=mimalloc"
elif [[ $TARGET =~ ^riscv64.*$ || $TARGET =~ ^loongarch64.*$ || $TARGET =~ ^aarch64.*$ ]]; then
CORE_FEATURES="--features=mimalloc"
else
CORE_FEATURES="--features=jemalloc"
fi
cargo build --release --target $TARGET --package=easytier-web --features=embed
mv ./target/$TARGET/release/easytier-web"$SUFFIX" ./target/$TARGET/release/easytier-web-embed"$SUFFIX"
cargo build --release --target $TARGET $CORE_FEATURES
SUFFIX=""
fi
# Copied and slightly modified from @lmq8267 (https://github.com/lmq8267)
- name: Build Core & Cli (X86_64 FreeBSD)
uses: vmactions/freebsd-vm@670398e4236735b8b65805c3da44b7a511fb8b27
if: ${{ endsWith(matrix.TARGET, 'freebsd') }}
if [[ "$TARGET" =~ (x86_64-unknown-linux-musl|aarch64-unknown-linux-musl|windows|darwin) ]]; then
BUILD=build
else
BUILD=zigbuild
fi
if [[ "$TARGET" =~ ^(riscv64|loongarch64|aarch64).*$ || "$TARGET" =~ (freebsd|windows) ]]; then
FEATURES="mimalloc"
else
FEATURES="jemalloc"
fi
cargo $BUILD --release --target $TARGET --package=easytier-web --features=embed
mv ./target/$TARGET/release/easytier-web"$SUFFIX" ./target/$TARGET/release/easytier-web-embed"$SUFFIX"
cargo $BUILD --release --target $TARGET --features=$FEATURES
- name: Build (MIPS)
if: ${{ contains(matrix.TARGET, 'mips') }}
env:
TARGET: ${{ matrix.TARGET }}
with:
envs: TARGET
release: ${{ matrix.BSD_VERSION }}
arch: x86_64
usesh: true
mem: 6144
cpu: 4
run: |
uname -a
echo $SHELL
pwd
ls -lah
whoami
env | sort
pkg install -y git protobuf llvm-devel sudo curl
curl --proto 'https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
. $HOME/.cargo/env
rustup set auto-self-update disable
rustup install 1.89
rustup default 1.89
export CC=clang
export CXX=clang++
export CARGO_TERM_COLOR=always
cargo build --release --verbose --target $TARGET --package=easytier-web --features=embed
mv ./target/$TARGET/release/easytier-web ./target/$TARGET/release/easytier-web-embed
cargo build --release --verbose --target $TARGET --features=mimalloc
RUSTC_BOOTSTRAP: 1
run: |
cargo build -r --target $TARGET -Z build-std=std,panic_abort --package=easytier --features=jemalloc
- name: Compress
run: |
mkdir -p ./artifacts/objects/
# windows is the only OS using a different convention for executable file name
if [[ $OS =~ ^windows.*$ && $TARGET =~ ^x86_64.*$ ]]; then
if [[ $OS =~ ^windows.*$ ]]; then
SUFFIX=.exe
cp easytier/third_party/x86_64/* ./artifacts/objects/
elif [[ $OS =~ ^windows.*$ && $TARGET =~ ^i686.*$ ]]; then
SUFFIX=.exe
cp easytier/third_party/i686/* ./artifacts/objects/
elif [[ $OS =~ ^windows.*$ && $TARGET =~ ^aarch64.*$ ]]; then
SUFFIX=.exe
cp easytier/third_party/arm64/* ./artifacts/objects/
case $TARGET in
x86_64*) ARCH_DIR=x86_64 ;;
i686*) ARCH_DIR=i686 ;;
aarch64*) ARCH_DIR=arm64 ;;
esac
if [[ -n "$ARCH_DIR" ]]; then
find "easytier/third_party/${ARCH_DIR}" -maxdepth 1 -type f \( -name "*.dll" -o -name "*.sys" \) -exec cp {} ./artifacts/objects/ \;
fi
fi
if [[ $GITHUB_REF_TYPE =~ ^tag$ ]]; then
TAG=$GITHUB_REF_NAME
else
TAG=$GITHUB_SHA
fi
if [[ $OS =~ ^ubuntu.*$ && ! $TARGET =~ (loongarch|freebsd) ]]; then
HOST_ARCH=$(uname -m)
case $HOST_ARCH in
x86_64) UPX_ARCH="amd64" ;;
aarch64) UPX_ARCH="arm64" ;;
*) UPX_ARCH="amd64" ;;
esac
if [[ $OS =~ ^ubuntu.*$ && ! $TARGET =~ ^.*freebsd$ && ! $TARGET =~ ^loongarch.*$ && ! $TARGET =~ ^riscv64.*$ ]]; then
UPX_VERSION=4.2.4
curl -L https://github.com/upx/upx/releases/download/v${UPX_VERSION}/upx-${UPX_VERSION}-amd64_linux.tar.xz -s | tar xJvf -
cp upx-${UPX_VERSION}-amd64_linux/upx .
./upx --lzma --best ./target/$TARGET/release/easytier-core"$SUFFIX"
./upx --lzma --best ./target/$TARGET/release/easytier-cli"$SUFFIX"
UPX_PKG="upx-${UPX_VERSION}-${UPX_ARCH}_linux"
curl -L "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/${UPX_PKG}.tar.xz" -s | tar xJvf -
cp "${UPX_PKG}/upx" .
UPX_BIN=./upx
fi
mv ./target/$TARGET/release/easytier-core"$SUFFIX" ./artifacts/objects/
mv ./target/$TARGET/release/easytier-cli"$SUFFIX" ./artifacts/objects/
if [[ ! $TARGET =~ ^mips.*$ ]]; then
mv ./target/$TARGET/release/easytier-web"$SUFFIX" ./artifacts/objects/
mv ./target/$TARGET/release/easytier-web-embed"$SUFFIX" ./artifacts/objects/
fi
for BIN in ./target/$TARGET/release/easytier-{core,cli,web,web-embed}"$SUFFIX"; do
if [[ -f "$BIN" ]]; then
if [[ -n "$UPX_BIN" ]]; then
$UPX_BIN --lzma --best "$BIN" || true
fi
mv "$BIN" ./artifacts/objects/
fi
done
mv ./artifacts/objects/* ./artifacts/
rm -rf ./artifacts/objects/
- name: Archive artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: easytier-${{ matrix.ARTIFACT_NAME }}
path: |
./artifacts/*
core-result:
if: needs.pre_job.outputs.should_skip != 'true' && always()
runs-on: ubuntu-latest
needs:
- pre_job
- build_web
- build
steps:
- name: Mark result as failed
if: needs.build.result != 'success'
run: exit 1
magisk_build:
needs:
- pre_job
- build_web
- build
if: needs.pre_job.outputs.should_skip != 'true' && always()
build_magisk:
runs-on: ubuntu-latest
needs: [ pre_job, build_web, build ]
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
steps:
- name: Checkout Code
uses: actions/checkout@v4 # 必须先检出代码才能获取模块配置
uses: actions/checkout@v5 # 必须先检出代码才能获取模块配置
# 下载二进制文件到独立目录
- name: Download Linux aarch64 binaries
@@ -322,10 +280,9 @@ jobs:
cp ./downloaded-binaries/easytier-cli ./easytier-contrib/easytier-magisk/
cp ./downloaded-binaries/easytier-web ./easytier-contrib/easytier-magisk/
# 上传生成的模块
- name: Upload Magisk Module
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: Easytier-Magisk
path: |
@@ -333,3 +290,12 @@ jobs:
!./easytier-contrib/easytier-magisk/build.sh
!./easytier-contrib/easytier-magisk/magisk_update.json
if-no-files-found: error
core-result:
runs-on: ubuntu-latest
needs: [ pre_job, build_web, build, build_magisk ]
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
steps:
- name: Mark result as failed
if: contains(needs.*.result, 'failure')
run: exit 1
+2 -2
View File
@@ -11,7 +11,7 @@ on:
image_tag:
description: 'Tag for this image build'
type: string
default: 'v2.5.0'
default: 'v2.6.4'
required: true
mark_latest:
description: 'Mark this image as latest'
@@ -31,7 +31,7 @@ jobs:
steps:
-
name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
-
name: Validate inputs
run: |
+48 -112
View File
@@ -5,7 +5,12 @@ on:
branches: ["develop", "main", "releases/**"]
pull_request:
branches: ["develop", "main"]
types: [opened, synchronize, reopened, ready_for_review]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
@@ -18,6 +23,7 @@ jobs:
pre_job:
# continue-on-error: true # Uncomment once integration is finished
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
# Map a step output to a job output
outputs:
should_skip: ${{ steps.skip_check.outputs.should_skip == 'true' && !startsWith(github.ref_name, 'releases/') }}
@@ -29,20 +35,20 @@ jobs:
concurrent_skipping: 'same_content_newer'
skip_after_successful_duplicate: 'true'
cancel_others: 'true'
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-gui/**", ".github/workflows/gui.yml", ".github/workflows/install_rust.sh", ".github/workflows/install_gui_dep.sh"]'
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-gui/**", ".github/workflows/gui.yml", ".github/actions/**", "easytier-web/frontend-lib/**"]'
build-gui:
strategy:
fail-fast: false
fail-fast: true
matrix:
include:
- TARGET: aarch64-unknown-linux-musl
OS: ubuntu-22.04
GUI_TARGET: aarch64-unknown-linux-gnu
ARTIFACT_NAME: linux-aarch64
- TARGET: x86_64-unknown-linux-musl
OS: ubuntu-22.04
OS: ubuntu-24.04
GUI_TARGET: x86_64-unknown-linux-gnu
ARTIFACT_NAME: linux-x86_64
- TARGET: aarch64-unknown-linux-musl
OS: ubuntu-24.04-arm
GUI_TARGET: aarch64-unknown-linux-gnu
ARTIFACT_NAME: linux-aarch64
- TARGET: x86_64-apple-darwin
OS: macos-latest
@@ -57,16 +63,14 @@ jobs:
OS: windows-latest
GUI_TARGET: x86_64-pc-windows-msvc
ARTIFACT_NAME: windows-x86_64
- TARGET: aarch64-pc-windows-msvc
OS: windows-latest
GUI_TARGET: aarch64-pc-windows-msvc
ARTIFACT_NAME: windows-arm64
- TARGET: i686-pc-windows-msvc
OS: windows-latest
GUI_TARGET: i686-pc-windows-msvc
ARTIFACT_NAME: windows-i686
- TARGET: aarch64-pc-windows-msvc
OS: windows-11-arm
GUI_TARGET: aarch64-pc-windows-msvc
ARTIFACT_NAME: windows-arm64
runs-on: ${{ matrix.OS }}
env:
@@ -78,103 +82,39 @@ jobs:
needs: pre_job
if: needs.pre_job.outputs.should_skip != 'true'
steps:
- uses: actions/checkout@v3
- name: Install GUI dependencies (x86 only)
if: ${{ matrix.TARGET == 'x86_64-unknown-linux-musl' }}
run: bash ./.github/workflows/install_gui_dep.sh
- name: Install GUI cross compile (aarch64 only)
if: ${{ matrix.TARGET == 'aarch64-unknown-linux-musl' }}
run: |
# see https://tauri.app/v1/guides/building/linux/
echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy main restricted" | sudo tee /etc/apt/sources.list
echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy-updates main restricted" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy universe" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy-updates universe" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy multiverse" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy-updates multiverse" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy-backports main restricted universe multiverse" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=amd64] http://security.ubuntu.com/ubuntu/ jammy-security main restricted" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=amd64] http://security.ubuntu.com/ubuntu/ jammy-security universe" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=amd64] http://security.ubuntu.com/ubuntu/ jammy-security multiverse" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy main restricted" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-updates main restricted" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy universe" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-updates universe" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy multiverse" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-updates multiverse" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-backports main restricted universe multiverse" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-security main restricted" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-security universe" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-security multiverse" | sudo tee -a /etc/apt/sources.list
sudo dpkg --add-architecture arm64
sudo apt update
sudo apt install aptitude
sudo aptitude install -y libgstreamer1.0-0:arm64 gstreamer1.0-plugins-base:arm64 gstreamer1.0-plugins-good:arm64 \
libgstreamer-gl1.0-0:arm64 libgstreamer-plugins-base1.0-0:arm64 libgstreamer-plugins-good1.0-0:arm64 libwebkit2gtk-4.1-0:arm64 \
libwebkit2gtk-4.1-dev:arm64 libssl-dev:arm64 gcc-aarch64-linux-gnu libsoup-3.0-dev:arm64 libjavascriptcoregtk-4.1-dev:arm64
echo "PKG_CONFIG_SYSROOT_DIR=/usr/aarch64-linux-gnu/" >> "$GITHUB_ENV"
echo "PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig/" >> "$GITHUB_ENV"
- uses: actions/checkout@v5
- name: Set current ref as env variable
run: |
echo "GIT_DESC=$(git log -1 --format=%cd.%h --date=format:%Y-%m-%d_%H:%M:%S)" >> $GITHUB_ENV
- uses: actions/setup-node@v4
- name: Prepare build environment
uses: ./.github/actions/prepare-build
with:
node-version: 22
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install frontend dependencies
run: |
pnpm -r install
pnpm -r build
target: ${{ matrix.TARGET }}
gui: true
pnpm: true
pnpm-build-filter: ''
token: ${{ secrets.GITHUB_TOKEN }}
- uses: Swatinem/rust-cache@v2
with:
# The prefix cache key, this can be changed to start a new cache manually.
# default: "v0-rust"
prefix-key: ""
- name: Install rust target
run: bash ./.github/workflows/install_rust.sh
- name: Setup protoc
uses: arduino/setup-protoc@v3
with:
# GitHub repo token to use to avoid rate limiter
repo-token: ${{ secrets.GITHUB_TOKEN }}
shared-key: "gui-registry"
cache-targets: "false"
- name: copy correct DLLs
if: ${{ matrix.OS == 'windows-latest' }}
if: ${{ contains(matrix.GUI_TARGET, 'windows') }}
run: |
if [[ $GUI_TARGET =~ ^aarch64.*$ ]]; then
cp ./easytier/third_party/arm64/* ./easytier-gui/src-tauri/
elif [[ $GUI_TARGET =~ ^i686.*$ ]]; then
cp ./easytier/third_party/i686/* ./easytier-gui/src-tauri/
else
cp ./easytier/third_party/x86_64/* ./easytier-gui/src-tauri/
case $TARGET in
x86_64*) ARCH_DIR=x86_64 ;;
i686*) ARCH_DIR=i686 ;;
aarch64*) ARCH_DIR=arm64 ;;
esac
if [[ -n "$ARCH_DIR" ]]; then
find "./easytier/third_party/${ARCH_DIR}" -maxdepth 1 -type f \( -name "*.dll" -o -name "*.sys" \) -exec cp {} ./easytier-gui/src-tauri/ \;
fi
- name: Build GUI
@@ -182,10 +122,9 @@ jobs:
uses: tauri-apps/tauri-action@v0
with:
projectPath: ./easytier-gui
# https://tauri.app/v1/guides/building/linux/#cross-compiling-tauri-applications-for-arm-based-devices
args: --verbose --target ${{ matrix.GUI_TARGET }} ${{ matrix.OS == 'ubuntu-22.04' && contains(matrix.TARGET, 'aarch64') && '--bundles deb' || '' }}
args: --verbose --target ${{ matrix.GUI_TARGET }}
- name: Compress
- name: Collect artifact
run: |
mkdir -p ./artifacts/objects/
@@ -194,36 +133,33 @@ jobs:
else
TAG=$GITHUB_SHA
fi
# copy gui bundle, gui is built without specific target
if [[ $OS =~ ^windows.*$ ]]; then
if [[ $GUI_TARGET =~ windows ]]; then
mv ./target/$GUI_TARGET/release/bundle/nsis/*.exe ./artifacts/objects/
elif [[ $OS =~ ^macos.*$ ]]; then
elif [[ $GUI_TARGET =~ darwin ]]; then
mv ./target/$GUI_TARGET/release/bundle/dmg/*.dmg ./artifacts/objects/
elif [[ $OS =~ ^ubuntu.*$ && ! $TARGET =~ ^mips.*$ ]]; then
elif [[ $GUI_TARGET =~ linux ]]; then
mv ./target/$GUI_TARGET/release/bundle/deb/*.deb ./artifacts/objects/
if [[ $GUI_TARGET =~ ^x86_64.*$ ]]; then
# currently only x86 appimage is supported
mv ./target/$GUI_TARGET/release/bundle/appimage/*.AppImage ./artifacts/objects/
fi
mv ./target/$GUI_TARGET/release/bundle/rpm/*.rpm ./artifacts/objects/
mv ./target/$GUI_TARGET/release/bundle/appimage/*.AppImage ./artifacts/objects/
fi
mv ./artifacts/objects/* ./artifacts/
rm -rf ./artifacts/objects/
- name: Archive artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: easytier-gui-${{ matrix.ARTIFACT_NAME }}
path: |
./artifacts/*
gui-result:
if: needs.pre_job.outputs.should_skip != 'true' && always()
runs-on: ubuntu-latest
needs:
- pre_job
- build-gui
needs: [ pre_job, build-gui ]
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
steps:
- name: Mark result as failed
if: needs.build-gui.result != 'success'
if: contains(needs.*.result, 'failure')
run: exit 1
-11
View File
@@ -1,11 +0,0 @@
sudo apt update
sudo apt install -qq libwebkit2gtk-4.1-dev \
build-essential \
curl \
wget \
file \
libgtk-3-dev \
librsvg2-dev \
libxdo-dev \
libssl-dev \
patchelf
-61
View File
@@ -1,61 +0,0 @@
#!/usr/bin/env bash
# env needed:
# - TARGET
# - GUI_TARGET
# - OS
# dependencies are only needed on ubuntu as that's the only place where
# we make cross-compilation
if [[ $OS =~ ^ubuntu.*$ ]]; then
sudo apt-get update && sudo apt-get install -qq musl-tools libappindicator3-dev llvm clang
# https://github.com/cross-tools/musl-cross/releases
# if "musl" is a substring of TARGET, we assume that we are using musl
MUSL_TARGET=$TARGET
# if target is mips or mipsel, we should use soft-float version of musl
if [[ $TARGET =~ ^mips.*$ || $TARGET =~ ^mipsel.*$ ]]; then
MUSL_TARGET=${TARGET}sf
elif [[ $TARGET =~ ^riscv64gc-.*$ ]]; then
MUSL_TARGET=${TARGET/#riscv64gc-/riscv64-}
fi
if [[ $MUSL_TARGET =~ musl ]]; then
mkdir -p ./musl_gcc
wget --inet4-only -c https://github.com/cross-tools/musl-cross/releases/download/20250520/${MUSL_TARGET}.tar.xz -P ./musl_gcc/
tar xf ./musl_gcc/${MUSL_TARGET}.tar.xz -C ./musl_gcc/
sudo ln -sf $(pwd)/musl_gcc/${MUSL_TARGET}/bin/*gcc /usr/bin/
sudo ln -sf $(pwd)/musl_gcc/${MUSL_TARGET}/include/ /usr/include/musl-cross
sudo ln -sf $(pwd)/musl_gcc/${MUSL_TARGET}/${MUSL_TARGET}/sysroot/ ./musl_gcc/sysroot
sudo chmod -R a+rwx ./musl_gcc
fi
fi
# see https://github.com/rust-lang/rustup/issues/3709
rustup set auto-self-update disable
rustup install 1.89
rustup default 1.89
# mips/mipsel cannot add target from rustup, need compile by ourselves
if [[ $OS =~ ^ubuntu.*$ && $TARGET =~ ^mips.*$ ]]; then
cd "$PWD/musl_gcc/${MUSL_TARGET}/lib/gcc/${MUSL_TARGET}/15.1.0" || exit 255
# for panic-abort
cp libgcc_eh.a libunwind.a
# for mimalloc
ar x libgcc.a _ctzsi2.o _clz.o _bswapsi2.o
ar rcs libctz.a _ctzsi2.o _clz.o _bswapsi2.o
rustup toolchain install nightly-2025-09-01-x86_64-unknown-linux-gnu
rustup component add rust-src --toolchain nightly-2025-09-01-x86_64-unknown-linux-gnu
# https://github.com/rust-lang/rust/issues/128808
# remove it after Cargo or rustc fix this.
RUST_LIB_SRC=$HOME/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/
if [[ -f $RUST_LIB_SRC/library/Cargo.lock && ! -f $RUST_LIB_SRC/Cargo.lock ]]; then
cp -f $RUST_LIB_SRC/library/Cargo.lock $RUST_LIB_SRC/Cargo.lock
fi
else
rustup target add $TARGET
if [[ $GUI_TARGET != '' ]]; then
rustup target add $GUI_TARGET
fi
fi
+42 -64
View File
@@ -5,7 +5,12 @@ on:
branches: ["develop", "main", "releases/**"]
pull_request:
branches: ["develop", "main"]
types: [opened, synchronize, reopened, ready_for_review]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
@@ -18,6 +23,7 @@ jobs:
pre_job:
# continue-on-error: true # Uncomment once integration is finished
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
# Map a step output to a job output
outputs:
should_skip: ${{ steps.skip_check.outputs.should_skip == 'true' && !startsWith(github.ref_name, 'releases/') }}
@@ -29,25 +35,30 @@ jobs:
concurrent_skipping: 'same_content_newer'
skip_after_successful_duplicate: 'true'
cancel_others: 'true'
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-gui/**", "tauri-plugin-vpnservice/**", ".github/workflows/mobile.yml", ".github/workflows/install_rust.sh"]'
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-gui/**", "tauri-plugin-vpnservice/**", ".github/workflows/mobile.yml", ".github/actions/**"]'
build-mobile:
strategy:
fail-fast: false
fail-fast: true
matrix:
include:
- TARGET: android
OS: ubuntu-22.04
ARTIFACT_NAME: android
runs-on: ${{ matrix.OS }}
- TARGET: aarch64-linux-android
ARCH: aarch64
- TARGET: armv7-linux-androideabi
ARCH: armv7
- TARGET: i686-linux-android
ARCH: i686
- TARGET: x86_64-linux-android
ARCH: x86_64
runs-on: ubuntu-latest
env:
NAME: easytier
TARGET: ${{ matrix.TARGET }}
OS: ${{ matrix.OS }}
ARCH: ${{ matrix.ARCH }}
OSS_BUCKET: ${{ secrets.ALIYUN_OSS_BUCKET }}
needs: pre_job
if: needs.pre_job.outputs.should_skip != 'true'
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v5
- name: Set current ref as env variable
run: |
@@ -61,72 +72,41 @@ jobs:
- name: Setup Android SDK
uses: android-actions/setup-android@v3
with:
cmdline-tools-version: 11076708
packages: 'build-tools;34.0.0 ndk;26.0.10792818 tools platform-tools platforms;android-34 '
cmdline-tools-version: 12.0
packages: 'build-tools;34.0.0 ndk;26.0.10792818 platform-tools platforms;android-34 '
- name: Setup Android Environment
run: |
echo "$ANDROID_HOME/platform-tools" >> $GITHUB_PATH
echo "$ANDROID_HOME/ndk/26.0.10792818/toolchains/llvm/prebuilt/linux-x86_64/bin" >> $GITHUB_PATH
echo "NDK_HOME=$ANDROID_HOME/ndk/26.0.10792818/" > $GITHUB_ENV
echo "NDK_HOME=$ANDROID_HOME/ndk/26.0.10792818/" >> $GITHUB_ENV
- uses: actions/setup-node@v4
- name: Prepare build environment
uses: ./.github/actions/prepare-build
with:
node-version: 22
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install frontend dependencies
run: |
pnpm -r install
pnpm -r build
target: ${{ matrix.TARGET }}
gui: false
pnpm: true
pnpm-build-filter: ''
token: ${{ secrets.GITHUB_TOKEN }}
- uses: Swatinem/rust-cache@v2
with:
# The prefix cache key, this can be changed to start a new cache manually.
# default: "v0-rust"
prefix-key: ""
shared-key: "gui-registry"
cache-targets: "false"
- name: Install rust target
run: |
bash ./.github/workflows/install_rust.sh
rustup target add aarch64-linux-android
rustup target add armv7-linux-androideabi
rustup target add i686-linux-android
rustup target add x86_64-linux-android
- name: Setup protoc
uses: arduino/setup-protoc@v3
with:
# GitHub repo token to use to avoid rate limiter
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Build Android
- name: Build
run: |
cd easytier-gui
pnpm tauri android build
pnpm tauri android build --apk --target "$ARCH" --split-per-abi
- name: Compress
- name: Collect artifact
run: |
mkdir -p ./artifacts/objects/
mv easytier-gui/src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk ./artifacts/objects/
mv easytier-gui/src-tauri/gen/android/app/build/outputs/apk/*/release/*.apk ./artifacts/objects/
if [[ $GITHUB_REF_TYPE =~ ^tag$ ]]; then
TAG=$GITHUB_REF_NAME
@@ -134,23 +114,21 @@ jobs:
TAG=$GITHUB_SHA
fi
mv ./artifacts/objects/* ./artifacts
mv ./artifacts/objects/* ./artifacts/
rm -rf ./artifacts/objects/
- name: Archive artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: easytier-gui-${{ matrix.ARTIFACT_NAME }}
name: easytier-mobile-android-${{ matrix.ARCH }}
path: |
./artifacts/*
mobile-result:
if: needs.pre_job.outputs.should_skip != 'true' && always()
runs-on: ubuntu-latest
needs:
- pre_job
- build-mobile
needs: [ pre_job, build-mobile ]
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
steps:
- name: Mark result as failed
if: needs.build-mobile.result != 'success'
if: contains(needs.*.result, 'failure')
run: exit 1
+44
View File
@@ -0,0 +1,44 @@
name: Nix Check
on:
push:
branches: ["main", "develop"]
paths:
- "**/*.nix"
- "flake.lock"
- "rust-toolchain.toml"
pull_request:
branches: ["main", "develop"]
types: [opened, synchronize, reopened, ready_for_review]
paths:
- "**/*.nix"
- "flake.lock"
- "rust-toolchain.toml"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
check-full-shell:
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install Nix
uses: cachix/install-nix-action@v27
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Magic Nix Cache
uses: DeterminateSystems/magic-nix-cache-action@v6
- name: Warm up full devShell
run: nix develop .#full --command true
- name: Cargo check in flake environment
run: nix develop .#full --command cargo check
- name: Cargo build in flake environment
run: nix develop .#full --command cargo build
+95 -34
View File
@@ -3,10 +3,18 @@ name: EasyTier OHOS
on:
push:
branches: ["develop", "main", "releases/**"]
tags:
- 'v*'
- '!*-pre'
pull_request:
branches: ["develop", "main"]
types: [opened, synchronize, reopened, ready_for_review]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
@@ -17,18 +25,29 @@ defaults:
jobs:
cargo_fmt_check:
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: fmt check
- uses: actions/checkout@v5
- name: Prepare build environment
uses: ./.github/actions/prepare-build
with:
gui: false
pnpm: false
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
components: rustfmt
- name: Check formatting
working-directory: ./easytier-contrib/easytier-ohrs
run: |
bash ../../.github/workflows/install_rust.sh
rustup component add rustfmt
cargo fmt --all -- --check
run: cargo fmt --all -- --check
pre_job:
# continue-on-error: true # Uncomment once integration is finished
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
# Map a step output to a job output
outputs:
# do not skip push on branch starts with releases/
@@ -41,55 +60,71 @@ jobs:
concurrent_skipping: "same_content_newer"
skip_after_successful_duplicate: "true"
cancel_others: "true"
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-contrib/easytier-ohrs/**", ".github/workflows/ohos.yml", ".github/workflows/install_rust.sh"]'
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-contrib/easytier-ohrs/**", ".github/workflows/ohos.yml", ".github/actions/**"]'
build-ohos:
runs-on: ubuntu-latest
needs: pre_job
env:
OHPM_PUBLISH_CODE: ${{ secrets.OHPM_PUBLISH_CODE }}
if: needs.pre_job.outputs.should_skip != 'true'
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
sudo apt-get install -qq \
build-essential \
wget \
unzip \
git \
pkg-config curl libgl1-mesa-dev expect
sudo apt-get clean
- name: Count commits since last tag on upstream main
- name: Resolve easytier version
run: |
set -e
UPSTREAM_REPO="https://github.com/EasyTier/EasyTier.git"
git remote add upstream "$UPSTREAM_REPO" 2>/dev/null || true
git fetch upstream --tags --force
git fetch --unshallow upstream main || git fetch upstream main
git fetch --tags upstream --force
# upstream/main 最新提交
git fetch upstream main
# cargo 版本
CARGO_VERSION=$(cargo metadata --format-version 1 --no-deps --manifest-path easytier/Cargo.toml \
| jq -r '.packages[0].version')
# 获取 upstream/main 最新 tag
LAST_TAG=$(git describe --tags --abbrev=0 upstream/main 2>/dev/null || echo "")
LAST_TAG_VERSION="${LAST_TAG#v}"
if [ -z "$LAST_TAG" ]; then
# 语义版本比较
version_gt() {
[ "$(printf '%s\n' "$1" "$2" | sort -V | tail -n1)" = "$1" ] && [ "$1" != "$2" ]
}
if [ -z "$LAST_TAG_VERSION" ]; then
BASE_VERSION="$CARGO_VERSION"
DIFF_COUNT=$(git rev-list --count upstream/main)
elif version_gt "$CARGO_VERSION" "$LAST_TAG_VERSION"; then
BASE_VERSION="$CARGO_VERSION"
DIFF_COUNT=0
else
BASE_VERSION="$LAST_TAG_VERSION"
DIFF_COUNT=$(git rev-list --count "${LAST_TAG}..upstream/main")
fi
echo "TAG_COMMIT_DIFF=$DIFF_COUNT"
echo "TAG_COMMIT_DIFF=$DIFF_COUNT" >> $GITHUB_ENV
- name: Get easytier version
run: |
EASYTIER_CARGO_VERSION=$(cargo metadata --format-version 1 --no-deps --manifest-path easytier/Cargo.toml \
| jq -r '.packages[0].version')
EASYTIER_VERSION="${EASYTIER_CARGO_VERSION}-${TAG_COMMIT_DIFF}"
echo "EASYTIER_VERSION=${EASYTIER_VERSION}" >> $GITHUB_ENV
COMMIT_HASH=$(git rev-parse --short upstream/main)
EASYTIER_VERSION="${BASE_VERSION}-${DIFF_COUNT}-${COMMIT_HASH}"
echo "EASYTIER_VERSION=$EASYTIER_VERSION"
echo "EASYTIER_VERSION=$EASYTIER_VERSION" >> $GITHUB_ENV
cd ./easytier-contrib/easytier-ohrs/package
jq --arg v "$EASYTIER_VERSION" '.version = $v' oh-package.json5 > oh-package.tmp.json5
mv oh-package.tmp.json5 oh-package.json5
- name: Generate CHANGELOG.md for current commit
working-directory: ./easytier-contrib/easytier-ohrs/package
run: |
@@ -115,6 +150,15 @@ jobs:
run: |
echo "TARGET_ARCH=aarch64-linux-ohos" >> $GITHUB_ENV
rustup install stable
rustup default stable
rustup target add aarch64-unknown-linux-ohos
- uses: taiki-e/install-action@v2
with:
tool: ohrs
- name: Create clang wrapper script
run: |
sudo mkdir -p $OHOS_NDK_HOME/native/llvm
@@ -128,38 +172,50 @@ jobs:
EOF
sudo chmod +x $OHOS_NDK_HOME/native/llvm/aarch64-unknown-linux-ohos-clang.sh
- name: Build
- name: Build latest Har
working-directory: ./easytier-contrib/easytier-ohrs
run: |
sudo apt-get install -y llvm clang lldb lld
sudo apt-get install -y protobuf-compiler
bash ../../.github/workflows/install_rust.sh
source env.sh
cargo install ohrs
rustup target add aarch64-unknown-linux-ohos
cargo update easytier
ohrs doctor
ohrs build --release --arch aarch
ohrs artifact
mv package.har easytier-ohrs.har
- name: Build Release Package
if: startsWith(github.ref, 'refs/tags/')
working-directory: ./easytier-contrib/easytier-ohrs
run: |
echo "🎉 Official Release detected. Building easytier-release..."
TAG_NAME="${{ github.ref_name }}"
TAG_VERSION="${TAG_NAME#v}"
echo "Release Version: $TAG_VERSION"
cd package
jq --arg v "$TAG_VERSION" '.name = "easytier-release" | .version = $v' oh-package.json5 > oh-package.tmp.json5 && mv oh-package.tmp.json5 oh-package.json5
cd ..
ohrs build --release --arch aarch
cd dist/arm64-v8a
mv libeasytier_ohrs.so libeasytier_release.so
cd ../..
ohrs artifact
mv package.har easytier-release.har
- name: Upload artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: easytier-ohos
path: |
./easytier-contrib/easytier-ohrs/easytier-ohrs.har
./easytier-contrib/easytier-ohrs/dist/arm64-v8a/libeasytier_ohrs.so
retention-days: 5
if-no-files-found: error
- name: Publish To Center Ohpm
if: github.event_name == 'push'
working-directory: ./easytier-contrib/easytier-ohrs
env:
OHPM_PUBLISH_CODE: ${{ secrets.OHPM_PUBLISH_CODE }}
OHPM_PRIVATE_KEY: ${{ secrets.OHPM_PRIVATE_KEY }}
OHPM_KEY_PASSPHRASE: ${{ secrets.OHPM_KEY_PASSPHRASE }}
if: ${{ env.OHPM_PUBLISH_CODE != '' && github.event_name == 'push' }}
run: |
ohpm config set publish_id "$OHPM_PUBLISH_CODE"
ohpm config set publish_registry https://ohpm.openharmony.cn/ohpm
@@ -176,10 +232,15 @@ jobs:
ohpm publish easytier-ohrs.har
- name: Publish To Private Ohpm
if: github.event_name == 'push'
working-directory: ./easytier-contrib/easytier-ohrs
if: ${{ env.OHPM_PUBLISH_CODE != '' && github.event_name == 'push' }}
run: |
printf '%s' "${{ secrets.CODEARTS_PRIVATE_OHPM }}" > ~/.ohpm/.ohpmrc
ohpm config set strict_ssl false
ohpm publish easytier-ohrs.har
if [ -f "easytier-release.har" ]; then
echo "🚀 Publishing Release package..."
ohpm publish easytier-release.har
fi
curl --header "Content-Type: application/json" --request POST --data "{}" ${{ secrets.CODEARTS_WEBHOOKS }}
+3 -3
View File
@@ -18,7 +18,7 @@ on:
version:
description: 'Version for this release'
type: string
default: 'v2.5.0'
default: 'v2.6.4'
required: true
make_latest:
description: 'Mark this release as latest'
@@ -35,7 +35,7 @@ jobs:
steps:
-
name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Download Core Artifact
uses: dawidd6/action-download-artifact@v11
@@ -92,4 +92,4 @@ jobs:
files: |
./zipped_assets/*
token: ${{ secrets.GITHUB_TOKEN }}
tag_name: ${{ inputs.version }}
tag_name: ${{ inputs.version }}
+116 -68
View File
@@ -2,12 +2,18 @@ name: EasyTier Test
on:
push:
branches: ["develop", "main"]
branches: [ "develop", "main" ]
pull_request:
branches: ["develop", "main"]
branches: [ "develop", "main" ]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
# RUSTC_WRAPPER: "sccache"
# SCCACHE_GHA_ENABLED: "true"
defaults:
run:
@@ -28,22 +34,104 @@ jobs:
# All of these options are optional, so you can remove them if you are happy with the defaults
concurrent_skipping: 'never'
skip_after_successful_duplicate: 'true'
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", ".github/workflows/test.yml", ".github/workflows/install_gui_dep.sh", ".github/workflows/install_rust.sh"]'
test:
runs-on: ubuntu-22.04
needs: pre_job
if: needs.pre_job.outputs.should_skip != 'true'
steps:
- uses: actions/checkout@v3
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", ".github/workflows/test.yml", ".github/actions/**"]'
- name: Setup protoc
uses: arduino/setup-protoc@v3
check:
name: Run linters & check
runs-on: ubuntu-latest
needs: pre_job
if: needs.pre_job.outputs.should_skip != 'true'
steps:
- uses: actions/checkout@v5
- name: Prepare build environment
uses: ./.github/actions/prepare-build
with:
# GitHub repo token to use to avoid rate limiter
repo-token: ${{ secrets.GITHUB_TOKEN }}
gui: true
pnpm: true
token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
components: rustfmt,clippy
rustflags: ''
- uses: taiki-e/install-action@cargo-hack
- name: Check formatting
if: ${{ !cancelled() }}
run: cargo fmt --all -- --check
- name: Check Clippy
if: ${{ !cancelled() }}
run: cargo clippy --all-targets --features full --all -- -D warnings
- name: Check features
if: ${{ !cancelled() }}
run: cargo hack check --package easytier --each-feature --exclude-features macos-ne --verbose
- name: Check Cargo.lock is up to date
if: ${{ !cancelled() }}
run: |
if ! cargo metadata --format-version 1 --locked > /dev/null; then
echo "::error::Cargo.lock is out of date. Run cargo generate-lockfile or cargo build locally, then commit Cargo.lock."
exit 1
fi
pre-test:
name: Build test
runs-on: ubuntu-latest
needs: pre_job
if: needs.pre_job.outputs.should_skip != 'true'
steps:
- uses: actions/checkout@v5
- name: Prepare build environment
uses: ./.github/actions/prepare-build
with:
gui: true
pnpm: true
token: ${{ secrets.GITHUB_TOKEN }}
- uses: Swatinem/rust-cache@v2
- uses: taiki-e/install-action@nextest
- name: Archive test
run: cargo nextest archive --archive-file tests.tar.zst --package easytier --features full
- uses: actions/upload-artifact@v5
with:
name: tests
path: tests.tar.zst
retention-days: 1
test_matrix:
name: Test (${{ matrix.name }})
runs-on: ubuntu-latest
needs: [ pre_job, pre-test ]
if: needs.pre_job.outputs.should_skip != 'true'
strategy:
fail-fast: false
matrix:
include:
- name: "easytier"
opts: "-E 'not test(tests::three_node)' --test-threads 1 --no-fail-fast"
- name: "three_node"
opts: "-E 'test(tests::three_node) and not test(subnet_proxy_three_node_test)' --test-threads 1 --no-fail-fast"
- name: "three_node::subnet_proxy_three_node_test"
opts: "-E 'test(subnet_proxy_three_node_test)' --test-threads 1 --no-fail-fast"
steps:
- uses: actions/checkout@v5
- name: Setup tools for test
run: sudo apt install bridge-utils
- name: Setup upnpd for test
run: |
sudo apt-get update
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y miniupnpd miniupnpd-iptables iptables
- name: Setup system for test
run: |
@@ -53,63 +141,23 @@ jobs:
sudo sysctl net.ipv6.conf.lo.disable_ipv6=0
sudo ip addr add 2001:db8::2/64 dev lo
- uses: actions/setup-node@v4
- uses: taiki-e/install-action@nextest
- name: Download tests
uses: actions/download-artifact@v4
with:
node-version: 22
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install frontend dependencies
run: |
pnpm -r install
pnpm -r --filter "./easytier-web/*" build
- name: Cargo cache
uses: actions/cache@v4
with:
path: |
~/.cargo
./target
key: ${{ runner.os }}-cargo-test-${{ hashFiles('**/Cargo.lock') }}
- name: Install GUI dependencies (Used by clippy)
run: |
bash ./.github/workflows/install_gui_dep.sh
bash ./.github/workflows/install_rust.sh
rustup component add rustfmt
rustup component add clippy
- name: Check formatting
if: ${{ !cancelled() }}
run: cargo fmt --all -- --check
- name: Check Clippy
if: ${{ !cancelled() }}
# NOTE: tauri need `dist` dir in build.rs
run: |
mkdir -p easytier-gui/dist
cargo clippy --all-targets --all-features --all -- -D warnings
name: tests
- name: Run tests
run: |
sudo prlimit --pid $$ --nofile=1048576:1048576
sudo -E env "PATH=$PATH" cargo test --no-default-features --features=full --verbose -- --test-threads=1
sudo chown -R $USER:$USER ./target
sudo chown -R $USER:$USER ~/.cargo
sudo -E env "PATH=$PATH" cargo nextest run --archive-file tests.tar.zst ${{ matrix.opts }}
test:
runs-on: ubuntu-latest
needs: [ pre_job, check, test_matrix ]
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
steps:
- name: Mark result as failed
if: contains(needs.*.result, 'failure')
run: exit 1
+3 -3
View File
@@ -26,7 +26,7 @@ Thank you for your interest in contributing to EasyTier! This document provides
#### Required Tools
- Node.js v21 or higher
- pnpm v9 or higher
- Rust toolchain (version 1.89)
- Rust toolchain (version 1.95)
- LLVM and Clang
- Protoc (Protocol Buffers compiler)
@@ -79,8 +79,8 @@ sudo apt install -y bridge-utils
2. Install dependencies:
```bash
# Install Rust toolchain
rustup install 1.89
rustup default 1.89
rustup install 1.95
rustup default 1.95
# Install project dependencies
pnpm -r install
+3 -3
View File
@@ -34,7 +34,7 @@
#### 必需工具
- Node.js v21 或更高版本
- pnpm v9 或更高版本
- Rust 工具链(版本 1.89
- Rust 工具链(版本 1.95
- LLVM 和 Clang
- ProtocProtocol Buffers 编译器)
@@ -87,8 +87,8 @@ sudo apt install -y bridge-utils
2. 安装依赖:
```bash
# 安装 Rust 工具链
rustup install 1.89
rustup default 1.89
rustup install 1.95
rustup default 1.95
# 安装项目依赖
pnpm -r install
Generated
+2191 -1135
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -14,6 +14,10 @@ exclude = [
"easytier-contrib/easytier-ohrs", # it needs ohrs sdk
]
[workspace.package]
edition = "2024"
rust-version = "1.95"
[profile.dev]
panic = "unwind"
debug = 2
+31 -28
View File
@@ -48,40 +48,43 @@
Choose the installation method that best suits your needs:
Linux (Recommended):
```bash
# 1. Download pre-built binary (Recommended, All platforms supported)
# Visit https://github.com/EasyTier/EasyTier/releases
curl -fsSL "https://github.com/EasyTier/EasyTier/blob/main/script/install.sh?raw=true" | sudo bash -s install
```
# 2. Install via cargo (Latest development version)
cargo install --git https://github.com/EasyTier/EasyTier.git easytier
# 3. Install via Docker
# See https://easytier.cn/en/guide/installation.html#installation-methods
# 4. Linux Quick Install
wget -O- https://raw.githubusercontent.com/EasyTier/EasyTier/main/script/install.sh | sudo bash -s install
# 5. MacOS via Homebrew
Homebrew (MacOS/Linux):
```bash
brew tap brewforge/chinese
brew install --cask easytier-gui
# 6. OpenWrt Luci Web UI
# Visit https://github.com/EasyTier/luci-app-easytier
# 7. (Optional) Install shell completions:
easytier-core --gen-autocomplete fish > ~/.config/fish/completions/easytier-core.fish
easytier-cli gen-autocomplete fish > ~/.config/fish/completions/easytier-cli.fish
```
Windows (Recommended, run with administrator privileges):
```powershell
irm "https://github.com/EasyTier/EasyTier/blob/main/script/install.ps1?raw=true" | iex
```
Install via cargo (Latest development version):
```bash
cargo install --git https://github.com/EasyTier/EasyTier.git easytier
```
[Install pre-built binary](https://github.com/EasyTier/EasyTier/releases) (Recommended, All platforms supported)
[Install via Docker](https://easytier.cn/en/guide/installation.html#installation-methods)
[Install OpenWrt ipk package](https://github.com/EasyTier/luci-app-easytier)
Additional steps:
[One-Click Register Service](https://easytier.cn/en/guide/network/oneclick-install-as-service.html) (Automatically start when the system boots and run in the background)
### 🚀 Basic Usage
#### Quick Networking with Shared Nodes
EasyTier supports quick networking using shared public nodes. When you don't have a public IP, you can use the free shared nodes provided by the EasyTier community. Nodes will automatically attempt NAT traversal and establish P2P connections. When P2P fails, data will be relayed through shared nodes.
The currently deployed shared public node is `tcp://public.easytier.cn:11010`.
When using shared nodes, each node entering the network needs to provide the same `--network-name` and `--network-secret` parameters as the unique identifier of the network.
Taking two nodes as an example (Please use more complex network name to avoid conflicts):
@@ -90,14 +93,14 @@ Taking two nodes as an example (Please use more complex network name to avoid co
```bash
# Run with administrator privileges
sudo easytier-core -d --network-name abc --network-secret abc -p tcp://public.easytier.cn:11010
sudo easytier-core -d --network-name abc --network-secret abc -p tcp://<SharedNodeIP>:11010
```
2. Run on Node B:
```bash
# Run with administrator privileges
sudo easytier-core -d --network-name abc --network-secret abc -p tcp://public.easytier.cn:11010
sudo easytier-core -d --network-name abc --network-secret abc -p tcp://<SharedNodeIP>:11010
```
After successful execution, you can check the network status using `easytier-cli`:
@@ -105,9 +108,9 @@ After successful execution, you can check the network status using `easytier-cli
```text
| ipv4 | hostname | cost | lat_ms | loss_rate | rx_bytes | tx_bytes | tunnel_proto | nat_type | id | version |
| ------------ | -------------- | ----- | ------ | --------- | -------- | -------- | ------------ | -------- | ---------- | --------------- |
| 10.126.126.1 | abc-1 | Local | * | * | * | * | udp | FullCone | 439804259 | 2.5.0-70e69a38~ |
| 10.126.126.2 | abc-2 | p2p | 3.452 | 0 | 17.33 kB | 20.42 kB | udp | FullCone | 390879727 | 2.5.0-70e69a38~ |
| | PublicServer_a | p2p | 27.796 | 0.000 | 50.01 kB | 67.46 kB | tcp | Unknown | 3771642457 | 2.5.0-70e69a38~ |
| 10.126.126.1 | abc-1 | Local | * | * | * | * | udp | FullCone | 439804259 | 2.6.2-70e69a38~ |
| 10.126.126.2 | abc-2 | p2p | 3.452 | 0 | 17.33 kB | 20.42 kB | udp | FullCone | 390879727 | 2.6.2-70e69a38~ |
| | PublicServer_a | p2p | 27.796 | 0.000 | 50.01 kB | 67.46 kB | tcp | Unknown | 3771642457 | 2.6.2-70e69a38~ |
```
You can test connectivity between nodes:
@@ -124,7 +127,7 @@ To improve availability, you can connect to multiple shared nodes simultaneously
```bash
# Connect to multiple shared nodes
sudo easytier-core -d --network-name abc --network-secret abc -p tcp://public.easytier.cn:11010 -p udp://public.easytier.cn:11010
sudo easytier-core -d --network-name abc --network-secret abc -p tcp://<SharedNodeIP1>:11010 -p udp://<SharedNodeIP2>:11010
```
Once your network is set up successfully, you can easily configure it to start automatically on system boot. Refer to the [One-Click Register Service guide](https://easytier.cn/en/guide/network/oneclick-install-as-service.html) for step-by-step instructions on registering EasyTier as a system service.
+32 -30
View File
@@ -48,40 +48,42 @@
选择最适合您需求的安装方式:
Linux(推荐):
```bash
# 1. 下载预编译二进制文件(推荐,支持所有平台)
# 访问 https://github.com/EasyTier/EasyTier/releases
curl -fsSL "https://github.com/EasyTier/EasyTier/blob/main/script/install.sh?raw=true" | sudo bash -s install
```
# 2. 通过 cargo 安装(最新开发版本)
cargo install --git https://github.com/EasyTier/EasyTier.git easytier
# 3. 通过 Docker 安装
# 参见 https://easytier.cn/guide/installation.html#%E5%AE%89%E8%A3%85%E6%96%B9%E5%BC%8F
# 4. Linux 快速安装
wget -O- https://raw.githubusercontent.com/EasyTier/EasyTier/main/script/install.sh | sudo bash -s install
# 5. MacOS 通过 Homebrew 安装
HomebrewMacOS/Linux):
```bash
brew tap brewforge/chinese
brew install --cask easytier-gui
# 6. OpenWrt Luci Web 界面
# 访问 https://github.com/EasyTier/luci-app-easytier
# 7.(可选)安装 Shell 补全功能:
# Fish 补全
easytier-core --gen-autocomplete fish > ~/.config/fish/completions/easytier-core.fish
easytier-cli gen-autocomplete fish > ~/.config/fish/completions/easytier-cli.fish
```
Windows(推荐,请以管理员权限运行):
```powershell
irm "https://github.com/EasyTier/EasyTier/blob/main/script/install.ps1?raw=true" | iex
```
通过 cargo 安装(最新开发版本):
```bash
cargo install --git https://github.com/EasyTier/EasyTier.git easytier
```
[下载预编译文件](https://github.com/EasyTier/EasyTier/releases)(推荐,支持所有平台)
[通过 Docker 安装](https://easytier.cn/guide/installation.html#%E5%AE%89%E8%A3%85%E6%96%B9%E5%BC%8F)
[安装 OpenWrt ipk 软件包](https://github.com/EasyTier/luci-app-easytier)
附加步骤:
[一键注册系统服务](https://easytier.cn/guide/network/oneclick-install-as-service.html)(系统启动时自动后台运行)
### 🚀 基本用法
#### 使用共享节点快速组网
EasyTier 支持使用共享公共节点快速组网。当您没有公网 IP 时,可以使用 EasyTier 社区提供的免费共享节点。节点会自动尝试 NAT 穿透并建立 P2P 连接。当 P2P 失败时,数据将通过共享节点中继。
当前部署的共享公共节点是 `tcp://public.easytier.cn:11010`
EasyTier 支持使用共享节点快速组网。当您没有公网 IP 时,可以使用公共共享节点。节点会自动尝试 NAT 穿透并建立 P2P 连接。当 P2P 失败时,数据将通过共享节点中继。
使用共享节点时,每个进入网络的节点需要提供相同的 `--network-name``--network-secret` 参数作为网络的唯一标识符。
@@ -91,14 +93,14 @@ EasyTier 支持使用共享公共节点快速组网。当您没有公网 IP 时
```bash
# 以管理员权限运行
sudo easytier-core -d --network-name abc --network-secret abc -p tcp://public.easytier.cn:11010
sudo easytier-core -d --network-name abc --network-secret abc -p tcp://<共享节点IP>:11010
```
2. 在节点 B 上运行:
```bash
# 以管理员权限运行
sudo easytier-core -d --network-name abc --network-secret abc -p tcp://public.easytier.cn:11010
sudo easytier-core -d --network-name abc --network-secret abc -p tcp://<共享节点IP>:11010
```
执行成功后,可以使用 `easytier-cli` 检查网络状态:
@@ -106,9 +108,9 @@ sudo easytier-core -d --network-name abc --network-secret abc -p tcp://public.ea
```text
| ipv4 | hostname | cost | lat_ms | loss_rate | rx_bytes | tx_bytes | tunnel_proto | nat_type | id | version |
| ------------ | -------------- | ----- | ------ | --------- | -------- | -------- | ------------ | -------- | ---------- | --------------- |
| 10.126.126.1 | abc-1 | Local | * | * | * | * | udp | FullCone | 439804259 | 2.5.0-70e69a38~ |
| 10.126.126.2 | abc-2 | p2p | 3.452 | 0 | 17.33 kB | 20.42 kB | udp | FullCone | 390879727 | 2.5.0-70e69a38~ |
| | PublicServer_a | p2p | 27.796 | 0.000 | 50.01 kB | 67.46 kB | tcp | Unknown | 3771642457 | 2.5.0-70e69a38~ |
| 10.126.126.1 | abc-1 | Local | * | * | * | * | udp | FullCone | 439804259 | 2.6.2-70e69a38~ |
| 10.126.126.2 | abc-2 | p2p | 3.452 | 0 | 17.33 kB | 20.42 kB | udp | FullCone | 390879727 | 2.6.2-70e69a38~ |
| | PublicServer_a | p2p | 27.796 | 0.000 | 50.01 kB | 67.46 kB | tcp | Unknown | 3771642457 | 2.6.2-70e69a38~ |
```
您可以测试节点之间的连通性:
@@ -125,7 +127,7 @@ ping 10.126.126.2
```bash
# 连接多个共享节点
sudo easytier-core -d --network-name abc --network-secret abc -p tcp://public.easytier.cn:11010 -p udp://public.easytier.cn:11010
sudo easytier-core -d --network-name abc --network-secret abc -p tcp://<公共节点IP>:11010 -p udp://<公共节点IP>:11010
```
#### 去中心化组网
@@ -1,7 +1,7 @@
[package]
name = "easytier-android-jni"
version = "0.1.0"
edition = "2021"
edition.workspace = true
[lib]
crate-type = ["cdylib"]
@@ -1,7 +1,7 @@
use easytier::proto::api::manage::{NetworkInstanceRunningInfo, NetworkInstanceRunningInfoMap};
use jni::JNIEnv;
use jni::objects::{JClass, JObjectArray, JString};
use jni::sys::{jint, jstring};
use jni::JNIEnv;
use once_cell::sync::Lazy;
use std::ffi::{CStr, CString};
use std::ptr;
@@ -15,7 +15,7 @@ pub struct KeyValuePair {
}
// 声明外部 C 函数
extern "C" {
unsafe extern "C" {
fn set_tun_fd(inst_name: *const std::ffi::c_char, fd: std::ffi::c_int) -> std::ffi::c_int;
fn get_error_msg(out: *mut *const std::ffi::c_char);
fn free_string(s: *const std::ffi::c_char);
@@ -68,7 +68,7 @@ fn throw_exception(env: &mut JNIEnv, message: &str) {
}
/// 设置 TUN 文件描述符
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_setTunFd(
mut env: JNIEnv,
_class: JClass,
@@ -87,17 +87,17 @@ pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_setTunFd(
unsafe {
let result = set_tun_fd(inst_name_cstr.as_ptr(), fd);
if result != 0 {
if let Some(error) = get_last_error() {
throw_exception(&mut env, &error);
}
if result != 0
&& let Some(error) = get_last_error()
{
throw_exception(&mut env, &error);
}
result
}
}
/// 解析配置
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_parseConfig(
mut env: JNIEnv,
_class: JClass,
@@ -115,17 +115,17 @@ pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_parseConfig(
unsafe {
let result = parse_config(config_cstr.as_ptr());
if result != 0 {
if let Some(error) = get_last_error() {
throw_exception(&mut env, &error);
}
if result != 0
&& let Some(error) = get_last_error()
{
throw_exception(&mut env, &error);
}
result
}
}
/// 运行网络实例
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_runNetworkInstance(
mut env: JNIEnv,
_class: JClass,
@@ -143,17 +143,17 @@ pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_runNetworkInstance(
unsafe {
let result = run_network_instance(config_cstr.as_ptr());
if result != 0 {
if let Some(error) = get_last_error() {
throw_exception(&mut env, &error);
}
if result != 0
&& let Some(error) = get_last_error()
{
throw_exception(&mut env, &error);
}
result
}
}
/// 保持网络实例
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_retainNetworkInstance(
mut env: JNIEnv,
_class: JClass,
@@ -165,10 +165,10 @@ pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_retainNetworkInstance(
if instance_names.is_null() {
unsafe {
let result = retain_network_instance(ptr::null(), 0);
if result != 0 {
if let Some(error) = get_last_error() {
throw_exception(&mut env, &error);
}
if result != 0
&& let Some(error) = get_last_error()
{
throw_exception(&mut env, &error);
}
return result;
}
@@ -187,10 +187,10 @@ pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_retainNetworkInstance(
if array_length == 0 {
unsafe {
let result = retain_network_instance(ptr::null(), 0);
if result != 0 {
if let Some(error) = get_last_error() {
throw_exception(&mut env, &error);
}
if result != 0
&& let Some(error) = get_last_error()
{
throw_exception(&mut env, &error);
}
return result;
}
@@ -234,17 +234,17 @@ pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_retainNetworkInstance(
unsafe {
let result = retain_network_instance(c_string_ptrs.as_ptr(), c_string_ptrs.len());
if result != 0 {
if let Some(error) = get_last_error() {
throw_exception(&mut env, &error);
}
if result != 0
&& let Some(error) = get_last_error()
{
throw_exception(&mut env, &error);
}
result
}
}
/// 收集网络信息
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_collectNetworkInfos(
mut env: JNIEnv,
_class: JClass,
@@ -304,7 +304,7 @@ pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_collectNetworkInfos(
}
/// 获取最后的错误信息
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_getLastError(
env: JNIEnv,
_class: JClass,
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "easytier-ffi"
version = "0.1.0"
edition = "2021"
edition.workspace = true
[lib]
crate-type = ["cdylib"]
+9 -9
View File
@@ -30,7 +30,7 @@ fn set_error_msg(msg: &str) {
/// # Safety
/// Set the tun fd
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn set_tun_fd(
inst_name: *const std::ffi::c_char,
fd: std::ffi::c_int,
@@ -59,7 +59,7 @@ pub unsafe extern "C" fn set_tun_fd(
/// # Safety
/// Get the last error message
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn get_error_msg(out: *mut *const std::ffi::c_char) {
let msg_buf = ERROR_MSG.lock().unwrap();
if msg_buf.is_empty() {
@@ -74,7 +74,7 @@ pub unsafe extern "C" fn get_error_msg(out: *mut *const std::ffi::c_char) {
}
}
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "C" fn free_string(s: *const std::ffi::c_char) {
if s.is_null() {
return;
@@ -86,7 +86,7 @@ pub extern "C" fn free_string(s: *const std::ffi::c_char) {
/// # Safety
/// Parse the config
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn parse_config(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int {
let cfg_str = unsafe {
assert!(!cfg_str.is_null());
@@ -105,7 +105,7 @@ pub unsafe extern "C" fn parse_config(cfg_str: *const std::ffi::c_char) -> std::
/// # Safety
/// Run the network instance
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn run_network_instance(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int {
let cfg_str = unsafe {
assert!(!cfg_str.is_null());
@@ -144,7 +144,7 @@ pub unsafe extern "C" fn run_network_instance(cfg_str: *const std::ffi::c_char)
/// # Safety
/// Retain the network instance
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn retain_network_instance(
inst_names: *const *const std::ffi::c_char,
length: usize,
@@ -188,7 +188,7 @@ pub unsafe extern "C" fn retain_network_instance(
/// # Safety
/// Collect the network infos
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn collect_network_infos(
infos: *mut KeyValuePair,
max_length: usize,
@@ -215,7 +215,7 @@ pub unsafe extern "C" fn collect_network_infos(
if index >= max_length {
break;
}
let Some(key) = INSTANCE_MANAGER.get_network_instance_name(instance_id) else {
let Some(key) = INSTANCE_MANAGER.get_instance_name(instance_id) else {
continue;
};
// convert value to json string
@@ -228,7 +228,7 @@ pub unsafe extern "C" fn collect_network_infos(
};
infos[index] = KeyValuePair {
key: std::ffi::CString::new(key.clone()).unwrap().into_raw(),
key: std::ffi::CString::new(key).unwrap().into_raw(),
value: std::ffi::CString::new(value).unwrap().into_raw(),
};
index += 1;
+57 -26
View File
@@ -1,43 +1,74 @@
#!/data/adb/magisk/busybox sh
MODDIR=${0%/*}
MODULE_PROP="${MODDIR}/module.prop"
IP_RULE_SCRIPT="${MODDIR}/hotspot_iprule.sh"
ET_STATUS=""
REDIR_STATUS=""
# 更新module.prop文件中的description
IS_RUNNING=false
# 确保辅助脚本有执行权限
chmod +x "${IP_RULE_SCRIPT}" 2>/dev/null
# 更新 module.prop 文件中的 description
update_module_description() {
local status_message=$1
sed -i "/^description=/c\description=[状态]${status_message}" ${MODULE_PROP}
# 检查 module.prop 文件存在且 description 发生变化了再写入
if [ -f "${MODULE_PROP}" ]; then
local current_desc=$(grep "^description=" "${MODULE_PROP}")
local new_desc="description=[状态] ${status_message}"
if [ "${current_desc}" != "${new_desc}" ]; then
sed -i "s#^description=.*#${new_desc}#" "${MODULE_PROP}"
fi
fi
}
# 判断程序启动状态
if [ -f "${MODDIR}/disable" ]; then
ET_STATUS="已关闭"
elif pgrep -f 'easytier-core' >/dev/null; then
if [ -f "${MODDIR}/config/command_args"]; then
ET_STATUS="主程序已开启(启动参数模式)"
IS_RUNNING=false
ET_STATUS="主程序已关闭"
elif pgrep -f "${MODDIR}/easytier-core" >/dev/null; then
IS_RUNNING=true
if [ -f "${MODDIR}/config/command_args" ]; then
ET_STATUS="主程序正在运行(启动参数模式)"
else
ET_STATUS="主程序已开启(配置文件模式)"
ET_STATUS="主程序正在运行(配置文件模式"
fi
elif [ -z "$ET_STATUS" ]; then
# 既没 disable 也没运行,说明是异常停止或未启动
ET_STATUS="主程序启动失败或未运行"
fi
#ET_STATUS不存在说明开启模块未正常运行,不修改状态
if [ -n "$ET_STATUS" ]; then
if [ -f "${MODDIR}/enable_IP_rule" ]; then
rm -f "${MODDIR}/enable_IP_rule"
${MODDIR}/hotspot_iprule.sh del
REDIR_STATUS="转发已禁用"
echo "热点子网转发已禁用"
echo "[ET-NAT] IP rule disabled." >> "${MODDIR}/log.log"
else
touch "${MODDIR}/enable_IP_rule"
${MODDIR}/hotspot_iprule.sh del
${MODDIR}/hotspot_iprule.sh add_once
REDIR_STATUS="转发已激活"
echo "热点子网转发已激活,热点开启后将自动将热点加入转发网络(要求已配置本地网络cidr=参数)。转发规则将随着热点开关而自动开关。该状态将保持到转发被禁用为止。"
echo "[ET-NAT] IP rule enabled." >> "${MODDIR}/log.log"
fi
update_module_description "${ET_STATUS} | ${REDIR_STATUS}"
# 无论主程序是否运行,都允许切换“开关文件”的状态,以便下次生效
if [ -f "${MODDIR}/enable_IP_rule" ]; then
rm -f "${MODDIR}/enable_IP_rule"
"${IP_RULE_SCRIPT}" del >/dev/null 2>&1
REDIR_STATUS="转发已禁用"
echo "热点子网转发已禁用"
echo "[ET-NAT] Action: IP rule disabled." >> "${MODDIR}/log.log"
else
echo "主程序未正常启动,请先检查配置文件"
touch "${MODDIR}/enable_IP_rule"
if [ "$IS_RUNNING" = true ]; then
"${IP_RULE_SCRIPT}" del >/dev/null 2>&1
"${IP_RULE_SCRIPT}" add_once
echo "转发规则将立即生效,无需重启"
else
echo "主程序未运行,转发规则将在下次启动时生效"
fi
REDIR_STATUS="转发已激活"
echo "----------------------------------"
echo "热点子网转发已激活"
echo "热点开启后将自动将热点加入转发网络"
echo "需要在配置中提前配置好 cidr 参数"
echo "----------------------------------"
echo "[ET-NAT] Action: IP rule enabled." >> "${MODDIR}/log.log"
fi
sync
update_module_description "${ET_STATUS}| ${REDIR_STATUS}"
+19 -9
View File
@@ -1,9 +1,19 @@
ui_print '安装完成'
ui_print '当前架构为' + $ARCH
ui_print '当前系统版本为' + $API
ui_print '安装目录为: /data/adb/modules/easytier_magisk'
ui_print '配置文件位置: /data/adb/modules/easytier_magisk/config/config.toml'
ui_print '如果需要自定义启动参数,可将 /data/adb/modules/easytier_magisk/config/command_args_sample 重命名为 command_args,并修改其中内容,使用自定义启动参数时会忽略配置文件'
ui_print '修改配置文件后在magisk app禁用应用再启动即可生效'
ui_print '点击操作按钮可启动/关闭热点子网转发,配合easytier的子网代理功能实现手机热点访问easytier网络'
ui_print '记得重启'
SKIPMOUNT=false
PROPFILE=true
POSTFSDATA=true
LATESTARTSERVICE=true
set_perm_recursive $MODPATH 0 0 0777 0777
ui_print "系统架构为:$ARCH"
ui_print "系统 SDK 版本:$API"
ui_print "EasyTier 安装位置:/data/adb/modules/easytier_magisk"
ui_print "配置文件位置:/data/adb/modules/easytier_magisk/config/config.toml"
ui_print "如需使用启动参数模式,请将 /data/adb/modules/easytier_magisk/config/command_args_sample 重命名为 command_args,并修改其中的内容"
ui_print "config 目录中存在 command_args 文件时,模块会自动忽略 config.toml 文件"
ui_print "----------------------------------"
ui_print "注意!启动参数文件中不能存在 \" 和 ',配置文件则没有这个限制"
ui_print "----------------------------------"
ui_print "修改配置后无需重启设备,在 Magisk 中禁用 EasyTier 模块,等待 10 秒后重新启用即可让新配置生效"
ui_print "点击 Magisk 中模块左下角的“操作”按钮可以禁用或激活热点子网转发,使用该功能前需要在配置中提前配置好 cidr 参数"
ui_print "模块安装完成,重启设备生效"
@@ -2,64 +2,111 @@
MODDIR=${0%/*}
CONFIG_FILE="${MODDIR}/config/config.toml"
COMMAND_ARGS="${MODDIR}/config/command_args"
LOG_FILE="${MODDIR}/log.log"
MODULE_PROP="${MODDIR}/module.prop"
EASYTIER="${MODDIR}/easytier-core"
# 处理获取到的设备型号中可能出现的空格
BRAND=$(getprop ro.product.brand | tr ' ' '-')
MODEL=$(getprop ro.product.model | tr ' ' '-')
DEVICE_HOSTNAME="${BRAND}-${MODEL}"
REDIR_STATUS=""
# 更新module.prop文件中的description
# 更新 module.prop 文件中的 description
update_module_description() {
local status_message=$1
sed -i "/^description=/c\description=[状态]${status_message}" ${MODULE_PROP}
# 检查 module.prop 文件存在且 description 发生变化了再写入
if [ -f "${MODULE_PROP}" ]; then
local current_desc=$(grep "^description=" "${MODULE_PROP}")
local new_desc="description=[状态] ${status_message}"
if [ "${current_desc}" != "${new_desc}" ]; then
sed -i "s#^description=.*#${new_desc}#" "${MODULE_PROP}"
fi
fi
}
if [ -f "${MODDIR}/enable_IP_rule" ]; then
REDIR_STATUS="转发已激活"
else
REDIR_STATUS="转发已禁用"
fi
# 检查并初始化 TUN 设备
if [ ! -e /dev/net/tun ]; then
if [ ! -d /dev/net ]; then
mkdir -p /dev/net
fi
ln -s /dev/tun /dev/net/tun
fi
while true; do
if ls $MODDIR | grep -q "disable"; then
update_module_description "关闭中 | ${REDIR_STATUS}"
if pgrep -f 'easytier-core' >/dev/null; then
echo "开关控制$(date "+%Y-%m-%d %H:%M:%S") 进程已存在,正在关闭 ..."
pkill easytier-core # 关闭进程
fi
# 获取子网转发激活状态
if [ -f "${MODDIR}/enable_IP_rule" ]; then
REDIR_STATUS="转发已激活"
else
if ! pgrep -f 'easytier-core' >/dev/null; then
if [ ! -f "$CONFIG_FILE" ]; then
update_module_description "config.toml不存在"
sleep 3s
continue
fi
REDIR_STATUS="转发已禁用"
fi
# 如果 config 目录下存在 command_args 文件,则读取其中的内容作为启动参数
if [ -f "${MODDIR}/config/command_args" ]; then
TZ=Asia/Shanghai ${EASYTIER} $(cat ${MODDIR}/config/command_args) --hostname "$(getprop ro.product.brand)-$(getprop ro.product.model)" > ${LOG_FILE} &
sleep 5s # 等待easytier-core启动完成
update_module_description "主程序已开启(启动参数模式) | ${REDIR_STATUS}"
else
TZ=Asia/Shanghai ${EASYTIER} -c ${CONFIG_FILE} --hostname "$(getprop ro.product.brand)-$(getprop ro.product.model)" > ${LOG_FILE} &
sleep 5s # 等待easytier-core启动完成
update_module_description "主程序已开启(配置文件模式) | ${REDIR_STATUS}"
fi
ip rule add from all lookup main
if ! pgrep -f 'easytier-core' >/dev/null; then
update_module_descriptio "主程序启动失败,请检查配置文件"
fi
else
echo "开关控制$(date "+%Y-%m-%d %H:%M:%S") 进程已存在"
# 检查模块是否被禁用
if [ -f "${MODDIR}/disable" ]; then
update_module_description "主程序已关闭 | ${REDIR_STATUS}"
if pgrep -f "${EASYTIER}" >/dev/null; then
echo "开关控制 $(date "+%Y-%m-%d %H:%M:%S") 进程已存在,正在关闭"
pkill -f "${EASYTIER}"
fi
sleep 10s
continue
fi
sleep 3s # 暂停3秒后再次执行循环
done
# 检查进程是否已经在运行
if pgrep -f "${EASYTIER}" >/dev/null; then
sleep 10s
continue
fi
# 检查配置文件是否存在
if [ ! -f "${CONFIG_FILE}" ] && [ ! -f "${COMMAND_ARGS}" ]; then
update_module_description "缺少配置文件或启动参数文件"
sleep 10s
continue
fi
# 如果 config 目录下存在 command_args 文件,则读取其中的内容作为启动参数
if [ -f "${COMMAND_ARGS}" ]; then
# 启动参数模式
CMD_CONTENT=$(tr '\r\n' ' ' < "${COMMAND_ARGS}")
if echo "${CMD_CONTENT}" | grep -q "\-\-hostname"; then
FINAL_ARGS="${CMD_CONTENT}"
else
FINAL_ARGS="${CMD_CONTENT} --hostname ${DEVICE_HOSTNAME}"
fi
TZ=Asia/Shanghai "${EASYTIER}" ${FINAL_ARGS} > "${LOG_FILE}" 2>&1 &
STR_MODE="启动参数模式"
# 否则读取 config.toml 的内容作为启动参数
else
# 配置文件模式
if grep -q "^[[:space:]]*hostname[[:space:]]*=" "${CONFIG_FILE}"; then
TZ=Asia/Shanghai "${EASYTIER}" -c "${CONFIG_FILE}" > "${LOG_FILE}" 2>&1 &
else
TZ=Asia/Shanghai "${EASYTIER}" -c "${CONFIG_FILE}" --hostname "${DEVICE_HOSTNAME}" > "${LOG_FILE}" 2>&1 &
fi
STR_MODE="配置文件模式"
fi
# 等待进程启动
sleep 5s
# 启动后的扫尾工作
if pgrep -f "${EASYTIER}" >/dev/null; then
if ! ip rule show | grep -q "lookup main"; then
ip rule add from all lookup main
fi
update_module_description "主程序正在运行(${STR_MODE}| ${REDIR_STATUS}"
else
update_module_description "主程序启动失败,请检查配置文件或启动参数"
fi
sleep 10s
done
+1 -1
View File
@@ -1,6 +1,6 @@
id=easytier_magisk
name=EasyTier_Magisk
version=v2.5.0
version=v2.6.4
versionCode=1
author=EasyTier
description=easytier magisk module @EasyTier(https://github.com/EasyTier/EasyTier)
@@ -1,3 +1,5 @@
MODDIR=${0%/*}
pkill easytier-core # 结束 easytier-core 进程
rm -rf $MODDIR/*
pkill -f "${MODDIR}/easytier-core"
# 使用 ${MODDIR:?} 确保变量非空,避免执行 rm -rf /*
rm -rf "${MODDIR:?}/"*
+434 -46
View File
@@ -38,6 +38,20 @@ dependencies = [
"cpufeatures",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]]
name = "aho-corasick"
version = "1.1.3"
@@ -133,6 +147,12 @@ version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457"
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "async-recursion"
version = "1.1.1"
@@ -202,6 +222,12 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "atomic_refcell"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41e67cd8309bbd06cd603a9e693a784ac2e5d1e955f11286e355089fcab3047c"
[[package]]
name = "auto_impl"
version = "1.3.0"
@@ -254,14 +280,14 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bindgen"
version = "0.71.1"
version = "0.72.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3"
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
dependencies = [
"bitflags 2.9.4",
"cexpr",
"clang-sys",
"itertools 0.13.0",
"itertools 0.11.0",
"proc-macro2",
"quote",
"regex",
@@ -540,6 +566,16 @@ dependencies = [
"clap",
]
[[package]]
name = "clap_complete_nushell"
version = "4.5.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "685bc86fd34b7467e0532a4f8435ab107960d69a243785ef0275e571b35b641a"
dependencies = [
"clap",
"clap_complete",
]
[[package]]
name = "clap_derive"
version = "4.5.47"
@@ -598,6 +634,15 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "convert_case"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -746,6 +791,15 @@ version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2"
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
@@ -894,6 +948,17 @@ dependencies = [
"powerfmt",
]
[[package]]
name = "derivative"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
@@ -936,6 +1001,29 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "derive_more"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [
"convert_case 0.10.0",
"proc-macro2",
"quote",
"rustc_version",
"syn 2.0.106",
"unicode-xid",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -995,7 +1083,7 @@ checksum = "7454e41ff9012c00d53cf7f475c5e3afa3b91b7c90568495495e8d9bf47a1055"
[[package]]
name = "easytier"
version = "2.4.5"
version = "2.6.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -1004,6 +1092,7 @@ dependencies = [
"async-stream",
"async-trait",
"atomic-shim",
"atomic_refcell",
"auto_impl",
"base64 0.22.1",
"bitflags 2.9.4",
@@ -1011,16 +1100,23 @@ dependencies = [
"bytecodec",
"byteorder",
"bytes",
"cfg-if",
"cfg_aliases",
"chrono",
"cidr",
"clap",
"clap_complete",
"clap_complete_nushell",
"crossbeam",
"dashmap",
"dbus",
"derivative",
"derive_builder",
"derive_more",
"easytier-rpc-build",
"encoding",
"flume",
"forwarded-header-value",
"futures",
"gethostname",
"git-version",
@@ -1036,6 +1132,8 @@ dependencies = [
"humansize",
"humantime-serde",
"idna",
"indoc",
"itertools 0.14.0",
"kcp-sys",
"machine-uid",
"multimap",
@@ -1046,7 +1144,9 @@ dependencies = [
"network-interface",
"nix 0.29.0",
"once_cell",
"ordered_hash_map",
"parking_lot",
"paste",
"percent-encoding",
"petgraph 0.8.2",
"pin-project-lite",
@@ -1056,8 +1156,11 @@ dependencies = [
"prost-build",
"prost-reflect",
"prost-reflect-build",
"prost-types",
"prost-wkt",
"prost-wkt-build",
"prost-wkt-types",
"quinn",
"quinn-plaintext",
"rand 0.8.5",
"rcgen",
"regex",
@@ -1073,10 +1176,13 @@ dependencies = [
"sha2",
"shellexpand",
"smoltcp",
"snow",
"socket2 0.5.10",
"strum",
"stun_codec",
"sys-locale",
"tabled",
"terminal_size",
"thiserror 1.0.69",
"thunk-rs",
"time",
@@ -1091,16 +1197,19 @@ dependencies = [
"tracing",
"tracing-subscriber",
"tun-easytier",
"unicode-width 0.1.11",
"url",
"uuid",
"version-compare",
"which 7.0.3",
"wildmatch",
"winapi",
"windivert",
"windows 0.52.0",
"windows-service",
"windows-sys 0.52.0",
"winreg 0.52.0",
"x25519-dalek",
"zerocopy 0.7.35",
"zip",
"zstd",
@@ -1126,8 +1235,6 @@ dependencies = [
[[package]]
name = "easytier-rpc-build"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24829168c28f6a448f57d18116c255dcbd2b8c25e76dbc60f6cd16d68ad2cf07"
dependencies = [
"heck 0.5.0",
"prost-build",
@@ -1253,6 +1360,17 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "erased-serde"
version = "0.4.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec"
dependencies = [
"serde",
"serde_core",
"typeid",
]
[[package]]
name = "errno"
version = "0.3.14"
@@ -1264,10 +1382,19 @@ dependencies = [
]
[[package]]
name = "fastbloom"
version = "0.14.0"
name = "etherparse"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18c1ddb9231d8554c2d6bdf4cfaabf0c59251658c68b6c95cd52dd0c513a912a"
checksum = "827292ea592108849932ad8e30218f8b1f21c0dfd0696698a18b5d0aed62d990"
dependencies = [
"arrayvec",
]
[[package]]
name = "fastbloom"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7f34442dbe69c60fe8eaf58a8cafff81a1f278816d8ab4db255b3bef4ac3c4"
dependencies = [
"getrandom 0.3.3",
"libm",
@@ -1280,6 +1407,9 @@ name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
dependencies = [
"getrandom 0.2.16",
]
[[package]]
name = "fiat-crypto"
@@ -1310,6 +1440,18 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "flume"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be"
dependencies = [
"fastrand",
"futures-core",
"futures-sink",
"spin",
]
[[package]]
name = "fnv"
version = "1.0.7"
@@ -1346,6 +1488,16 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "forwarded-header-value"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9"
dependencies = [
"nonempty",
"thiserror 1.0.69",
]
[[package]]
name = "futures"
version = "0.3.31"
@@ -1496,6 +1648,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]]
name = "gimli"
version = "0.31.1"
@@ -1605,9 +1767,9 @@ checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d"
[[package]]
name = "heapless"
version = "0.9.1"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1edcd5a338e64688fbdcb7531a846cfd3476a54784dcb918a0844682bc7ada5"
checksum = "2af2455f757db2b292a9b1768c4b70186d443bcb3b316252d6b540aec1cd89ed"
dependencies = [
"hash32",
"stable_deref_trait",
@@ -2064,6 +2226,15 @@ dependencies = [
"hashbrown 0.16.0",
]
[[package]]
name = "indoc"
version = "2.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
dependencies = [
"rustversion",
]
[[package]]
name = "inout"
version = "0.1.4"
@@ -2073,6 +2244,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "inventory"
version = "0.3.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b"
dependencies = [
"rustversion",
]
[[package]]
name = "io-uring"
version = "0.7.10"
@@ -2161,15 +2341,6 @@ dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.14.0"
@@ -2230,7 +2401,7 @@ dependencies = [
[[package]]
name = "kcp-sys"
version = "0.1.0"
source = "git+https://github.com/EasyTier/kcp-sys?rev=71eff18c573a4a71bf99c7fabc6a8b9f211c84c1#71eff18c573a4a71bf99c7fabc6a8b9f211c84c1"
source = "git+https://github.com/EasyTier/kcp-sys?rev=94964794caaed5d388463137da59b97499619e5f#94964794caaed5d388463137da59b97499619e5f"
dependencies = [
"anyhow",
"auto_impl",
@@ -2503,7 +2674,7 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b27250baa967a15214e57384dd6228c59afbccb15ab8f97207c9758917544bf5"
dependencies = [
"convert_case",
"convert_case 0.8.0",
"proc-macro2",
"quote",
"semver",
@@ -2516,7 +2687,7 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c844efa85d53b5adc3b326520f3a108c3a737b7534ee10d406f81884e7e71b3c"
dependencies = [
"convert_case",
"convert_case 0.8.0",
"ctor",
"napi-derive-backend-ohos",
"proc-macro2",
@@ -2564,7 +2735,7 @@ dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-probe 0.1.6",
"openssl-sys",
"schannel",
"security-framework 2.11.1",
@@ -2689,6 +2860,12 @@ dependencies = [
"minimal-lexical",
]
[[package]]
name = "nonempty"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7"
[[package]]
name = "normpath"
version = "1.5.0"
@@ -2810,6 +2987,12 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.109"
@@ -2822,6 +3005,15 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "ordered_hash_map"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6c699f8a30f345785be969deed7eee4c73a5de58c7faf61d6a3251ef798ff61"
dependencies = [
"hashbrown 0.15.5",
]
[[package]]
name = "papergrid"
version = "0.12.0"
@@ -2874,12 +3066,12 @@ dependencies = [
[[package]]
name = "pem"
version = "3.0.5"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
"base64 0.22.1",
"serde",
"serde_core",
]
[[package]]
@@ -3045,6 +3237,18 @@ dependencies = [
"universal-hash",
]
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "portable-atomic"
version = "1.11.1"
@@ -3251,6 +3455,52 @@ dependencies = [
"prost",
]
[[package]]
name = "prost-wkt"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "497e1e938f0c09ef9cabe1d49437b4016e03e8f82fbbe5d1c62a9b61b9decae1"
dependencies = [
"chrono",
"inventory",
"prost",
"serde",
"serde_derive",
"serde_json",
"typetag",
]
[[package]]
name = "prost-wkt-build"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07b8bf115b70a7aa5af1fd5d6e9418492e9ccb6e4785e858c938e28d132a884b"
dependencies = [
"heck 0.5.0",
"prost",
"prost-build",
"prost-types",
"quote",
]
[[package]]
name = "prost-wkt-types"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8cdde6df0a98311c839392ca2f2f0bcecd545f86a62b4e3c6a49c336e970fe5"
dependencies = [
"chrono",
"prost",
"prost-build",
"prost-types",
"prost-wkt",
"prost-wkt-build",
"regex",
"serde",
"serde_derive",
"serde_json",
]
[[package]]
name = "quick-xml"
version = "0.38.3"
@@ -3281,10 +3531,22 @@ dependencies = [
]
[[package]]
name = "quinn-proto"
version = "0.11.13"
name = "quinn-plaintext"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
checksum = "f3e617feaeb6493018fa35fc47ae8b630ac8903d8159e9e747018841b99bad3d"
dependencies = [
"bytes",
"quinn-proto",
"seahash",
"tracing",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"fastbloom",
@@ -3652,14 +3914,14 @@ dependencies = [
[[package]]
name = "rustls-native-certs"
version = "0.8.1"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3"
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
dependencies = [
"openssl-probe",
"openssl-probe 0.2.1",
"rustls-pki-types",
"schannel",
"security-framework 3.5.0",
"security-framework 3.5.1",
]
[[package]]
@@ -3683,9 +3945,9 @@ dependencies = [
[[package]]
name = "rustls-platform-verifier"
version = "0.6.1"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be59af91596cac372a6942530653ad0c3a246cdd491aaa9dcaee47f88d67d5a0"
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
@@ -3696,10 +3958,10 @@ dependencies = [
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework 3.5.0",
"security-framework 3.5.1",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.59.0",
"windows-sys 0.61.0",
]
[[package]]
@@ -3761,6 +4023,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "seahash"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "security-framework"
version = "2.11.1"
@@ -3776,9 +4044,9 @@ dependencies = [
[[package]]
name = "security-framework"
version = "3.5.0"
version = "3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc198e42d9b7510827939c9a15f5062a0c913f3371d765977e586d2fe6c16f4a"
checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef"
dependencies = [
"bitflags 2.9.4",
"core-foundation 0.10.1",
@@ -3956,6 +4224,12 @@ version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe"
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "1.0.1"
@@ -3987,6 +4261,23 @@ dependencies = [
"managed",
]
[[package]]
name = "snow"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "599b506ccc4aff8cf7844bc42cf783009a434c1e26c964432560fb6d6ad02d82"
dependencies = [
"aes-gcm",
"blake2",
"chacha20poly1305",
"curve25519-dalek",
"getrandom 0.3.3",
"ring",
"rustc_version",
"sha2",
"subtle",
]
[[package]]
name = "socket2"
version = "0.5.10"
@@ -4007,6 +4298,15 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
dependencies = [
"lock_api",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.0"
@@ -4019,6 +4319,27 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "stun_codec"
version = "0.3.5"
@@ -4369,9 +4690,9 @@ dependencies = [
[[package]]
name = "tokio-websockets"
version = "0.8.3"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "842e11addde61da7c37ef205cd625ebcd7b607076ea62e4698f06bfd5fd01a03"
checksum = "dad543404f98bfc969aeb71994105c592acfc6c43323fddcd016bb208d1c65cb"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -4382,10 +4703,11 @@ dependencies = [
"httparse",
"ring",
"rustls-pki-types",
"simdutf8",
"tokio",
"tokio-rustls",
"tokio-util",
"webpki-roots 0.26.11",
"webpki-roots 1.0.2",
]
[[package]]
@@ -4617,12 +4939,42 @@ dependencies = [
"wintun",
]
[[package]]
name = "typeid"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "typenum"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
[[package]]
name = "typetag"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be2212c8a9b9bcfca32024de14998494cf9a5dfa59ea1b829de98bac374b86bf"
dependencies = [
"erased-serde",
"inventory",
"once_cell",
"serde",
"typetag-impl",
]
[[package]]
name = "typetag-impl"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27a7a9b72ba121f6f1f6c3632b85604cac41aedb5ddc70accbebb6cac83de846"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "unicase"
version = "2.8.1"
@@ -4653,6 +5005,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "universal-hash"
version = "0.5.1"
@@ -4895,9 +5253,9 @@ dependencies = [
[[package]]
name = "webpki-root-certs"
version = "1.0.2"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e4ffd8df1c57e87c325000a3d6ef93db75279dc3a231125aac571650f22b12a"
checksum = "36a29fc0408b113f68cf32637857ab740edfafdf460c326cd2afaa2d84cc05dc"
dependencies = [
"rustls-pki-types",
]
@@ -4987,6 +5345,36 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windivert"
version = "0.6.0"
source = "git+https://github.com/EasyTier/windivert-rust.git?rev=adcc56d1550f7b5377ec2b3429f413ee24a77375#adcc56d1550f7b5377ec2b3429f413ee24a77375"
dependencies = [
"etherparse",
"thiserror 1.0.69",
"windivert-sys",
"windows 0.48.0",
]
[[package]]
name = "windivert-sys"
version = "0.10.0"
source = "git+https://github.com/EasyTier/windivert-rust.git?rev=adcc56d1550f7b5377ec2b3429f413ee24a77375#adcc56d1550f7b5377ec2b3429f413ee24a77375"
dependencies = [
"cc",
"thiserror 1.0.69",
"windows 0.48.0",
]
[[package]]
name = "windows"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows"
version = "0.52.0"
+2 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "easytier-uptime"
version = "0.1.0"
edition = "2021"
edition.workspace = true
[dependencies]
tokio = { version = "1.0", features = ["full"] }
@@ -12,6 +12,7 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1.0", features = ["v4", "serde"] }
guarden = "0.1"
# Axum web framework
axum = { version = "0.8.4", features = ["macros"] }
+9 -9
View File
@@ -9,7 +9,7 @@
"version": "0.0.0",
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.7.9",
"axios": "^1.13.5",
"dayjs": "^1.11.13",
"element-plus": "^2.8.8",
"vue": "^3.5.18",
@@ -1220,13 +1220,13 @@
"license": "MIT"
},
"node_modules/axios": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
"integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
"version": "1.13.6",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
}
},
@@ -1616,9 +1616,9 @@
}
},
"node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
@@ -10,7 +10,7 @@
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.7.9",
"axios": "^1.13.5",
"dayjs": "^1.11.13",
"easytier-uptime-frontend": "link:",
"element-plus": "^2.8.8",
@@ -1,7 +1,7 @@
use std::ops::{Div, Mul};
use axum::extract::{Path, State};
use axum::Json;
use axum::extract::{Path, State};
use sea_orm::{
ColumnTrait, Condition, EntityTrait, IntoActiveModel, ModelTrait, Order, PaginatorTrait,
QueryFilter, QueryOrder, QuerySelect, Set, TryIntoModel,
@@ -14,7 +14,7 @@ use crate::api::{
models::*,
};
use crate::db::entity::{self, health_records, shared_nodes};
use crate::db::{operations::*, Db};
use crate::db::{Db, operations::*};
use crate::health_checker_manager::HealthCheckerManager;
use axum_extra::extract::Query;
use std::sync::Arc;
@@ -273,7 +273,7 @@ pub struct InstanceFilterParams {
use crate::config::AppConfig;
use axum::http::{HeaderMap, StatusCode};
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
use serde::Serialize;
#[derive(Debug, Serialize, Deserialize)]
@@ -370,19 +370,19 @@ pub async fn admin_get_nodes(
let ids = NodeOperations::filter_node_ids_by_tag(&app_state.db, &tag).await?;
filtered_ids = Some(ids);
}
if let Some(tags) = filters.tags {
if !tags.is_empty() {
let ids_any = NodeOperations::filter_node_ids_by_tags_any(&app_state.db, &tags).await?;
filtered_ids = match filtered_ids {
Some(mut existing) => {
existing.extend(ids_any);
existing.sort();
existing.dedup();
Some(existing)
}
None => Some(ids_any),
};
}
if let Some(tags) = filters.tags
&& !tags.is_empty()
{
let ids_any = NodeOperations::filter_node_ids_by_tags_any(&app_state.db, &tags).await?;
filtered_ids = match filtered_ids {
Some(mut existing) => {
existing.extend(ids_any);
existing.sort();
existing.dedup();
Some(existing)
}
None => Some(ids_any),
};
}
if let Some(ids) = filtered_ids {
if ids.is_empty() {
@@ -1,5 +1,5 @@
use axum::routing::{delete, get, post, put};
use axum::Router;
use axum::routing::{delete, get, post, put};
use tower_http::compression::CompressionLayer;
use tower_http::cors::CorsLayer;
@@ -1,7 +1,7 @@
use crate::db::entity::*;
use crate::db::Db;
use crate::db::entity::*;
use sea_orm::*;
use tokio::time::{sleep, Duration};
use tokio::time::{Duration, sleep};
use tracing::{error, info, warn};
/// 数据清理策略配置
@@ -5,12 +5,12 @@ pub mod operations;
use std::fmt;
use sea_orm::{
prelude::*, sea_query::OnConflict, ColumnTrait as _, DatabaseConnection, DbErr, EntityTrait,
QueryFilter as _, Set, SqlxSqliteConnector, Statement, TransactionTrait as _,
ColumnTrait as _, DatabaseConnection, DbErr, EntityTrait, QueryFilter as _, Set,
SqlxSqliteConnector, Statement, TransactionTrait as _, prelude::*, sea_query::OnConflict,
};
use sea_orm_migration::MigratorTrait as _;
use serde::{Deserialize, Serialize};
use sqlx::{migrate::MigrateDatabase as _, Sqlite, SqlitePool};
use sqlx::{Sqlite, SqlitePool, migrate::MigrateDatabase as _};
use crate::migrator;
@@ -1,8 +1,8 @@
use crate::api::CreateNodeRequest;
use crate::db::entity::*;
use crate::db::Db;
use crate::db::HealthStats;
use crate::db::HealthStatus;
use crate::db::entity::*;
use sea_orm::*;
use std::collections::{HashMap, HashSet};
@@ -7,21 +7,21 @@ use std::{
use anyhow::Context as _;
use dashmap::DashMap;
use easytier::{
common::{
config::{ConfigFileControl, ConfigLoader, NetworkIdentity, PeerConfig, TomlConfigLoader},
scoped_task::ScopedTask,
common::config::{
ConfigFileControl, ConfigLoader, NetworkIdentity, PeerConfig, TomlConfigLoader,
},
defer,
instance_manager::NetworkInstanceManager,
};
use guarden::defer;
use serde::{Deserialize, Serialize};
use sqlx::any;
use tokio_util::task::AbortOnDropHandle;
use tracing::{debug, error, info, instrument, warn};
use crate::db::{
Db, HealthStatus,
entity::shared_nodes,
operations::{HealthOperations, NodeOperations},
Db, HealthStatus,
};
pub struct HealthCheckOneNode {
@@ -240,7 +240,7 @@ pub struct HealthChecker {
db: Db,
instance_mgr: Arc<NetworkInstanceManager>,
inst_id_map: DashMap<i32, uuid::Uuid>,
node_tasks: DashMap<i32, ScopedTask<()>>,
node_tasks: DashMap<i32, AbortOnDropHandle<()>>,
node_records: Arc<DashMap<i32, HealthyMemRecord>>,
node_cfg: Arc<DashMap<i32, TomlConfigLoader>>,
}
@@ -359,6 +359,7 @@ impl HealthChecker {
)
.parse()
.with_context(|| "failed to parse peer uri")?,
peer_public_key: None,
}]);
let inst_id = inst_id.unwrap_or(uuid::Uuid::new_v4());
@@ -464,7 +465,7 @@ impl HealthChecker {
}
// 启动健康检查任务
let task = ScopedTask::from(tokio::spawn(Self::node_health_check_task(
let task = AbortOnDropHandle::new(tokio::spawn(Self::node_health_check_task(
node_id,
cfg.get_id(),
Arc::clone(&self.instance_mgr),
@@ -1,11 +1,11 @@
use std::{collections::HashSet, sync::Arc, time::Duration};
use anyhow::Context as _;
use tokio::time::{interval, Interval};
use tokio::time::{Interval, interval};
use tracing::{error, info};
use crate::{
db::{entity::shared_nodes, operations::NodeOperations, Db},
db::{Db, entity::shared_nodes, operations::NodeOperations},
health_checker::HealthChecker,
};
+6 -4
View File
@@ -10,8 +10,8 @@ mod migrator;
use api::routes::create_routes;
use clap::Parser;
use config::AppConfig;
use db::{operations::NodeOperations, Db};
use easytier::utils::init_logger;
use db::{Db, operations::NodeOperations};
use easytier::common::log;
use health_checker::HealthChecker;
use health_checker_manager::HealthCheckerManager;
use std::env;
@@ -42,14 +42,16 @@ async fn main() -> anyhow::Result<()> {
let config = AppConfig::default();
// 初始化日志
let _ = init_logger(&config.logging, false);
let _ = log::init(&config.logging, false);
// 解析命令行参数
let args = Args::parse();
// 如果提供了管理员密码,设置环境变量
if let Some(password) = args.admin_password {
env::set_var("ADMIN_PASSWORD", password);
unsafe {
env::set_var("ADMIN_PASSWORD", password);
}
}
tracing::info!(
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "easytier-gui",
"type": "module",
"version": "2.5.0",
"version": "2.6.4",
"private": true,
"packageManager": "pnpm@9.12.1+sha512.e5a7e52a4183a02d5931057f7a0dbff9d5e9ce3161e33fa68ae392125b79282a8a8a470a51dfc8a0ed86221442eb2fb57019b0990ed24fab519bf0e1bc5ccfc4",
"scripts": {
@@ -53,10 +53,10 @@
"unplugin-vue-markdown": "^0.26.2",
"unplugin-vue-router": "^0.10.8",
"uuid": "^10.0.0",
"vite": "^5.4.8",
"vite-plugin-vue-devtools": "^8.0.5",
"vite": "^5.4.21",
"vite-plugin-vue-devtools": "^7.4.6",
"vite-plugin-vue-layouts": "^0.11.0",
"vue-i18n": "^10.0.0",
"vue-tsc": "^2.1.10"
}
}
}
+12 -11
View File
@@ -1,9 +1,9 @@
[package]
name = "easytier-gui"
version = "2.5.0"
version = "2.6.4"
description = "EasyTier GUI"
authors = ["you"]
edition = "2021"
edition.workspace = true
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -11,15 +11,6 @@ edition = "2021"
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.0.0-rc", features = [] }
# enable thunk-rs when compiling for x86_64 or i686 windows
[target.x86_64-pc-windows-msvc.build-dependencies]
thunk-rs = { git = "https://github.com/easytier/thunk.git", default-features = false, features = ["win7"] }
[target.i686-pc-windows-msvc.build-dependencies]
thunk-rs = { git = "https://github.com/easytier/thunk.git", default-features = false, features = ["win7"] }
[dependencies]
# wry 0.47 may crash on android, see https://github.com/EasyTier/EasyTier/issues/527
@@ -54,6 +45,8 @@ tauri-plugin-os = "2.3.0"
uuid = "1.17.0"
async-trait = "0.1.89"
url = { version = "2.5", features = ["serde"] }
[target.'cfg(target_os = "windows")'.dependencies]
windows = { version = "0.52", features = ["Win32_Foundation", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }
winapi = { version = "0.3.9", features = ["securitybaseapi", "processthreadsapi"] }
@@ -64,6 +57,14 @@ libc = "0.2"
[target.'cfg(target_os = "macos")'.dependencies]
security-framework-sys = "2.9.0"
[build-dependencies]
tauri-build = { version = "2.0.0-rc", features = [] }
thunk-rs = { git = "https://github.com/easytier/thunk.git", default-features = false, features = [
"win7",
] }
[features]
# This feature is used for production builds or when a dev server is not specified, DO NOT REMOVE!!
custom-protocol = ["tauri/custom-protocol"]
+12 -12
View File
@@ -1,12 +1,12 @@
fn main() {
// enable thunk-rs when target os is windows and arch is x86_64 or i686
#[cfg(target_os = "windows")]
if !std::env::var("TARGET")
.unwrap_or_default()
.contains("aarch64")
{
thunk::thunk();
}
tauri_build::build();
}
use std::env;
fn main() {
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
// enable thunk-rs when target os is windows and arch is x86_64 or i686
if target_os == "windows" && (target_arch == "x86" || target_arch == "x86_64") {
thunk::thunk();
}
tauri_build::build();
}
@@ -36,6 +36,7 @@
"core:tray:allow-set-show-menu-on-left-click",
"core:tray:allow-set-tooltip",
"vpnservice:allow-ping",
"vpnservice:allow-get-vpn-status",
"vpnservice:allow-prepare-vpn",
"vpnservice:allow-start-vpn",
"vpnservice:allow-stop-vpn",
@@ -47,4 +48,4 @@
"os:allow-platform",
"os:allow-locale"
]
}
}
@@ -1,5 +1,6 @@
import java.util.Properties
import java.io.FileInputStream
import groovy.json.JsonSlurper
plugins {
id("com.android.application")
@@ -14,6 +15,35 @@ val tauriProperties = Properties().apply {
}
}
val versionPattern = Regex("""^(\d+)\.(\d+)\.(\d+)$""")
val tauriVersionName = tauriProperties.getProperty("tauri.android.versionName")?.ifBlank { null } ?: run {
val tauriConfFile = file("../../../tauri.conf.json")
check(tauriConfFile.exists()) { "Missing tauri.conf.json at ${tauriConfFile.path}" }
val tauriConf = tauriConfFile.reader(Charsets.UTF_8).use { JsonSlurper().parse(it) as? Map<*, *> }
?: error("Failed to parse ${tauriConfFile.path} as a JSON object")
tauriConf["version"] as? String
?: error("Missing string field \"version\" in ${tauriConfFile.path}")
}
val tauriVersionMatch = versionPattern.matchEntire(tauriVersionName)
?: error("Android version must use x.y.z format, but got \"$tauriVersionName\"")
val tauriVersionCode = if (tauriProperties.getProperty("tauri.android.versionName")?.ifBlank { null } != null) {
val versionCodeProp = tauriProperties.getProperty("tauri.android.versionCode")
if (versionCodeProp != null) {
versionCodeProp.toIntOrNull()
?: error("Property \"tauri.android.versionCode\" must be an integer, but got \"$versionCodeProp\"")
} else {
val (major, minor, patch) = tauriVersionMatch.destructured
major.toInt() * 1_000_000 + minor.toInt() * 1_000 + patch.toInt()
}
} else {
val (major, minor, patch) = tauriVersionMatch.destructured
major.toInt() * 1_000_000 + minor.toInt() * 1_000 + patch.toInt()
}
android {
compileSdk = 34
namespace = "com.kkrainbow.easytier"
@@ -22,8 +52,8 @@ android {
applicationId = "com.kkrainbow.easytier"
minSdk = 24
targetSdk = 34
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
versionCode = tauriVersionCode
versionName = tauriVersionName
}
signingConfigs {
create("release") {
@@ -82,4 +112,4 @@ dependencies {
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
}
apply(from = "tauri.build.gradle.kts")
apply(from = "tauri.build.gradle.kts")
+1 -1
View File
@@ -4,7 +4,7 @@
*--------------------------------------------------------------------------------------------*/
use super::Command;
use anyhow::{anyhow, Result};
use anyhow::{Result, anyhow};
use std::env;
use std::ffi::OsStr;
use std::process::{Command as StdCommand, Output};
+57 -9
View File
@@ -16,6 +16,8 @@
use super::Command;
use anyhow::Result;
use std::env;
use std::fs::File;
use std::io::Read as _;
use std::path::PathBuf;
use std::process::{ExitStatus, Output};
@@ -23,13 +25,15 @@ use std::ffi::{CString, OsString};
use std::io;
use std::mem;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::io::FromRawFd;
use std::os::unix::process::ExitStatusExt;
use std::path::Path;
use std::ptr;
use libc::{fcntl, fileno, waitpid, EINTR, F_GETOWN};
use libc::{EINTR, SHUT_WR, fileno, wait};
use security_framework_sys::authorization::{
errAuthorizationSuccess, kAuthorizationFlagDefaults, kAuthorizationFlagDestroyRights,
AuthorizationCreate, AuthorizationExecuteWithPrivileges, AuthorizationFree, AuthorizationRef,
errAuthorizationSuccess, kAuthorizationFlagDefaults, kAuthorizationFlagDestroyRights,
};
const ENV_PATH: &str = "PATH";
@@ -71,7 +75,7 @@ macro_rules! make_cstring {
};
}
unsafe fn gui_runas(prog: *const i8, argv: *const *const i8) -> i32 {
unsafe fn gui_runas(prog: *const i8, argv: *const *const i8) -> io::Result<ExitStatus> {
let mut authref: AuthorizationRef = ptr::null_mut();
let mut pipe: *mut libc::FILE = ptr::null_mut();
@@ -82,7 +86,7 @@ unsafe fn gui_runas(prog: *const i8, argv: *const *const i8) -> i32 {
&mut authref,
) != errAuthorizationSuccess
{
return -1;
return Err(io::Error::last_os_error());
}
if AuthorizationExecuteWithPrivileges(
authref,
@@ -93,22 +97,66 @@ unsafe fn gui_runas(prog: *const i8, argv: *const *const i8) -> i32 {
) != errAuthorizationSuccess
{
AuthorizationFree(authref, kAuthorizationFlagDestroyRights);
return -1;
return Err(io::Error::last_os_error());
}
let fd = fileno(pipe);
if fd == -1 {
AuthorizationFree(authref, kAuthorizationFlagDestroyRights);
return Err(io::Error::last_os_error());
}
// We never send input to the elevated GUI. Close the parent write half so
// the child sees EOF on stdin instead of waiting forever.
if libc::shutdown(fd, SHUT_WR) == -1 {
let err = io::Error::last_os_error();
libc::fclose(pipe);
AuthorizationFree(authref, kAuthorizationFlagDestroyRights);
return Err(err);
}
// AuthorizationExecuteWithPrivileges wires the tool's stdin/stdout to a
// bidirectional pipe. Drain stdout so the child can't block on a full pipe.
let read_fd = libc::dup(fd);
if read_fd == -1 {
let err = io::Error::last_os_error();
libc::fclose(pipe);
AuthorizationFree(authref, kAuthorizationFlagDestroyRights);
return Err(err);
}
let mut pipe_file = unsafe { File::from_raw_fd(read_fd) };
let mut sink = [0_u8; 8192];
loop {
match pipe_file.read(&mut sink) {
Ok(0) => break,
Ok(_) => {}
Err(err) if err.kind() == io::ErrorKind::Interrupted => continue,
Err(err) => {
libc::fclose(pipe);
AuthorizationFree(authref, kAuthorizationFlagDestroyRights);
return Err(err);
}
}
}
let pid = fcntl(fileno(pipe), F_GETOWN, 0);
let mut status = 0;
loop {
let r = waitpid(pid, &mut status, 0);
let r = wait(&mut status);
if r == -1 && io::Error::last_os_error().raw_os_error() == Some(EINTR) {
continue;
} else if r == -1 {
let err = io::Error::last_os_error();
libc::fclose(pipe);
AuthorizationFree(authref, kAuthorizationFlagDestroyRights);
return Err(err);
} else {
break;
}
}
libc::fclose(pipe);
AuthorizationFree(authref, kAuthorizationFlagDestroyRights);
status
Ok(ExitStatus::from_raw(status))
}
fn runas_root_gui(cmd: &Command) -> io::Result<ExitStatus> {
@@ -126,7 +174,7 @@ fn runas_root_gui(cmd: &Command) -> io::Result<ExitStatus> {
let mut argv: Vec<_> = args.iter().map(|x| x.as_ptr()).collect();
argv.push(ptr::null());
unsafe { Ok(mem::transmute(gui_runas(prog.as_ptr(), argv.as_ptr()))) }
unsafe { gui_runas(prog.as_ptr(), argv.as_ptr()) }
}
/// The implementation of state check and elevated executing varies on each platform
@@ -11,11 +11,11 @@ use std::process::{ExitStatus, Output};
use winapi::shared::minwindef::{DWORD, LPVOID};
use winapi::um::processthreadsapi::{GetCurrentProcess, OpenProcessToken};
use winapi::um::securitybaseapi::GetTokenInformation;
use winapi::um::winnt::{TokenElevation, HANDLE, TOKEN_ELEVATION, TOKEN_QUERY};
use windows::core::{w, HSTRING, PCWSTR};
use winapi::um::winnt::{HANDLE, TOKEN_ELEVATION, TOKEN_QUERY, TokenElevation};
use windows::Win32::Foundation::HWND;
use windows::Win32::UI::Shell::ShellExecuteW;
use windows::Win32::UI::WindowsAndMessaging::SW_HIDE;
use windows::core::{HSTRING, PCWSTR, w};
/// The implementation of state check and elevated executing varies on each platform
impl Command {
+394 -111
View File
@@ -14,16 +14,23 @@ use easytier::rpc_service::remote_client::{
};
use easytier::web_client::{self, WebClient};
use easytier::{
common::config::{ConfigLoader, FileLoggerConfig, LoggingConfigBuilder, TomlConfigLoader},
common::{
config::{
ConfigLoader, ConfigSource, FileLoggerConfig, LoggingConfigBuilder, TomlConfigLoader,
},
log,
},
instance_manager::NetworkInstanceManager,
launcher::NetworkConfig,
rpc_service::ApiRpcServer,
tunnel::TunnelListener,
tunnel::ring::RingTunnelListener,
utils::{self},
tunnel::tcp::TcpTunnelListener,
utils::panic::setup_panic_handler,
};
use std::ops::Deref;
use std::sync::Arc;
use tokio::sync::{RwLock, RwLockReadGuard};
use tokio::sync::{Mutex, RwLock, RwLockReadGuard};
use uuid::Uuid;
use tauri::{AppHandle, Emitter, Manager as _};
@@ -40,8 +47,21 @@ static RPC_RING_UUID: once_cell::sync::Lazy<uuid::Uuid> =
static CLIENT_MANAGER: once_cell::sync::Lazy<RwLock<Option<manager::GUIClientManager>>> =
once_cell::sync::Lazy::new(|| RwLock::new(None));
static RING_RPC_SERVER: once_cell::sync::Lazy<RwLock<Option<ApiRpcServer<RingTunnelListener>>>> =
once_cell::sync::Lazy::new(|| RwLock::new(None));
type BoxedTunnelListener = Box<dyn TunnelListener>;
#[derive(Clone, Copy, PartialEq, Eq)]
enum RpcServerKind {
Ring,
Tcp,
}
struct RpcServer {
kind: RpcServerKind,
_server: ApiRpcServer<BoxedTunnelListener>,
bind_url: Option<url::Url>,
}
static RPC_SERVER: once_cell::sync::Lazy<Mutex<Option<RpcServer>>> =
once_cell::sync::Lazy::new(|| Mutex::new(None));
static WEB_CLIENT: once_cell::sync::Lazy<RwLock<Option<WebClient>>> =
once_cell::sync::Lazy::new(|| RwLock::new(None));
@@ -100,7 +120,7 @@ async fn run_network_instance(
let client_manager = get_client_manager!()?;
let toml_config = cfg.gen_config().map_err(|e| e.to_string())?;
client_manager
.pre_run_network_instance_hook(&app, &toml_config)
.pre_run_network_instance_hook(&app, &toml_config, manager::PersistedConfigSource::User)
.await?;
client_manager
.handle_run_network_instance(app.clone(), cfg, save)
@@ -128,7 +148,6 @@ async fn collect_network_info(
#[tauri::command]
async fn set_logging_level(level: String) -> Result<(), String> {
println!("Setting logging level to: {}", level);
get_client_manager!()?
.set_logging_level(level.clone())
.await
@@ -173,7 +192,7 @@ async fn remove_network_instance(app: AppHandle, instance_id: String) -> Result<
.await
.map_err(|e| e.to_string())?;
client_manager
.post_remove_network_instances_hook(&app, &[instance_id])
.post_stop_network_instances_hook(&app)
.await?;
Ok(())
@@ -189,6 +208,20 @@ async fn update_network_config_state(
.parse()
.map_err(|e: uuid::Error| e.to_string())?;
let client_manager = get_client_manager!()?;
if !disabled {
let (cfg, source) = client_manager
.handle_get_network_config_with_source(app.clone(), instance_id)
.await
.map_err(|e| e.to_string())?;
let toml_config = cfg.gen_config().map_err(|e| e.to_string())?;
client_manager
.pre_run_network_instance_hook(
&app,
&toml_config,
manager::PersistedConfigSource::from_runtime_source(source),
)
.await?;
}
client_manager
.handle_update_network_state(app.clone(), instance_id, disabled)
.await
@@ -196,7 +229,11 @@ async fn update_network_config_state(
if disabled {
client_manager
.post_remove_network_instances_hook(&app, &[instance_id])
.post_stop_network_instances_hook(&app)
.await?;
} else {
client_manager
.post_run_network_instance_hook(&app, &instance_id)
.await?;
}
@@ -241,7 +278,7 @@ async fn get_config(app: AppHandle, instance_id: String) -> Result<NetworkConfig
#[tauri::command]
async fn load_configs(
app: AppHandle,
configs: Vec<NetworkConfig>,
configs: Vec<manager::StoredGuiConfig>,
enabled_networks: Vec<String>,
) -> Result<(), String> {
get_client_manager!()?
@@ -322,8 +359,25 @@ fn get_service_status() -> Result<&'static str, String> {
}
}
fn normalize_normal_mode_rpc_portal(portal: &str) -> Result<(url::Url, url::Url), String> {
let portal_url: url::Url = portal
.parse()
.map_err(|e| format!("invalid rpc portal: {:#}", e))?;
let bind_url = portal_url.clone();
let mut connect_url = portal_url.clone();
// if bind addr is 0.0.0.0, should convert to 127.0.0.1
if connect_url.host_str() == Some("0.0.0.0") {
connect_url.set_host(Some("127.0.0.1")).unwrap();
}
Ok((bind_url, connect_url))
}
#[tauri::command]
async fn init_rpc_connection(_app: AppHandle, url: Option<String>) -> Result<(), String> {
async fn init_rpc_connection(
_app: AppHandle,
is_normal_mode: bool,
url: Option<String>,
) -> Result<(), String> {
let mut client_manager_guard =
tokio::time::timeout(std::time::Duration::from_secs(5), CLIENT_MANAGER.write())
.await
@@ -331,41 +385,72 @@ async fn init_rpc_connection(_app: AppHandle, url: Option<String>) -> Result<(),
let mut instance_manager_guard = INSTANCE_MANAGER
.try_write()
.map_err(|_| "Failed to acquire write lock for instance manager")?;
let mut ring_rpc_server_guard = RING_RPC_SERVER
.try_write()
.map_err(|_| "Failed to acquire write lock for ring rpc server")?;
let mut rpc_server_guard = RPC_SERVER
.try_lock()
.map_err(|_| "Failed to acquire lock for rpc server")?;
let normal_mode = url.is_none();
if normal_mode {
let mut client_url = url.clone();
if is_normal_mode {
let instance_manager = if let Some(im) = instance_manager_guard.take() {
im
} else {
Arc::new(NetworkInstanceManager::new())
};
let rpc_server = if let Some(rpc_server) = ring_rpc_server_guard.take() {
rpc_server
let portal = url.and_then(|s| {
let trimmed = s.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
});
let (desired_kind, bind_url, connect_url) = if let Some(portal) = portal {
let (bind_url, connect_url) = normalize_normal_mode_rpc_portal(&portal)?;
(RpcServerKind::Tcp, Some(bind_url), Some(connect_url))
} else {
ApiRpcServer::from_tunnel(
RingTunnelListener::new(
format!("ring://{}", RPC_RING_UUID.deref()).parse().unwrap(),
),
instance_manager.clone(),
)
.with_rx_timeout(None)
.serve()
.await
.map_err(|e| e.to_string())?
(RpcServerKind::Ring, None, None)
};
let need_restart = rpc_server_guard
.as_ref()
.map(|x| x.kind != desired_kind || x.bind_url != bind_url)
.unwrap_or(true);
if need_restart {
*rpc_server_guard = None;
let tunnel: BoxedTunnelListener = match desired_kind {
RpcServerKind::Ring => Box::new(RingTunnelListener::new(
format!("ring://{}", RPC_RING_UUID.deref()).parse().unwrap(),
)),
RpcServerKind::Tcp => Box::new(TcpTunnelListener::new(
bind_url.clone().expect("tcp rpc must have bind url"),
)),
};
let rpc_server = ApiRpcServer::from_tunnel(tunnel, instance_manager.clone())
.with_rx_timeout(None)
.serve()
.await
.map_err(|e| e.to_string())?;
*rpc_server_guard = Some(RpcServer {
kind: desired_kind,
_server: rpc_server,
bind_url,
});
}
*instance_manager_guard = Some(instance_manager);
*ring_rpc_server_guard = Some(rpc_server);
client_url = connect_url.map(|u| u.to_string());
} else {
*ring_rpc_server_guard = None;
*rpc_server_guard = None;
}
let client_manager = tokio::time::timeout(
std::time::Duration::from_millis(1000),
manager::GUIClientManager::new(url),
manager::GUIClientManager::new(client_url),
)
.await
.map_err(|_| "connect remote rpc timed out".to_string())?
@@ -373,7 +458,7 @@ async fn init_rpc_connection(_app: AppHandle, url: Option<String>) -> Result<(),
.map_err(|e| format!("{:#}", e))?;
*client_manager_guard = Some(client_manager);
if !normal_mode {
if !is_normal_mode {
drop(WEB_CLIENT.write().await.take());
if let Some(instance_manager) = instance_manager_guard.take() {
instance_manager
@@ -405,12 +490,26 @@ async fn init_web_client(app: AppHandle, url: Option<String>) -> Result<(), Stri
.ok_or_else(|| "Instance manager is not available".to_string())?;
let hooks = Arc::new(manager::GuiHooks { app: app.clone() });
let machine_id_state_dir = app
.path()
.app_data_dir()
.with_context(|| "Failed to resolve machine id state directory")
.map_err(|e| format!("{:#}", e))?;
let web_client =
web_client::run_web_client(url.as_str(), None, None, instance_manager, Some(hooks))
.await
.with_context(|| "Failed to initialize web client")
.map_err(|e| format!("{:#}", e))?;
let web_client = web_client::run_web_client(
url.as_str(),
easytier::common::MachineIdOptions {
explicit_machine_id: None,
state_dir: Some(machine_id_state_dir),
},
None,
false,
instance_manager,
Some(hooks),
)
.await
.with_context(|| "Failed to initialize web client")
.map_err(|e| format!("{:#}", e))?;
*web_client_guard = Some(web_client);
Ok(())
}
@@ -450,31 +549,34 @@ async fn get_log_dir_path(app: tauri::AppHandle) -> Result<String, String> {
#[cfg(not(target_os = "android"))]
fn toggle_window_visibility(app: &tauri::AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let visible = if window.is_visible().unwrap_or_default() {
if window.is_minimized().unwrap_or_default() {
let _ = window.unminimize();
false
} else {
true
let visible = window.is_visible().unwrap_or_default();
let minimized = window.is_minimized().unwrap_or_default();
let focused = window.is_focused().unwrap_or_default();
let should_show = !visible || minimized || !focused;
if should_show {
if !visible {
let _ = window.show();
}
if minimized {
let _ = window.unminimize();
}
if !focused {
let _ = window.set_focus();
}
let _ = set_dock_visibility(app.clone(), true);
} else {
let _ = window.show();
false
};
if visible {
let _ = window.hide();
} else {
let _ = window.set_focus();
let _ = set_dock_visibility(app.clone(), false);
}
let _ = set_dock_visibility(app.clone(), !visible);
}
}
fn get_exe_path() -> String {
if let Ok(appimage_path) = std::env::var("APPIMAGE") {
if !appimage_path.is_empty() {
return appimage_path;
}
if let Ok(appimage_path) = std::env::var("APPIMAGE")
&& !appimage_path.is_empty()
{
return appimage_path;
}
std::env::current_exe()
.map(|p| p.to_string_lossy().to_string())
@@ -508,8 +610,8 @@ mod manager {
use easytier::proto::rpc_types::controller::BaseController;
use easytier::rpc_service::logger::LoggerRpcService;
use easytier::rpc_service::remote_client::PersistentConfig;
use easytier::tunnel::ring::RingTunnelConnector;
use easytier::tunnel::TunnelConnector;
use easytier::tunnel::ring::RingTunnelConnector;
use easytier::web_client::WebClientHooks;
pub(super) struct GuiHooks {
@@ -524,7 +626,11 @@ mod manager {
) -> Result<(), String> {
let client_manager = get_client_manager!()?;
client_manager
.pre_run_network_instance_hook(&self.app, cfg)
.pre_run_network_instance_hook(
&self.app,
cfg,
PersistedConfigSource::from_runtime_source(cfg.get_network_config_source()),
)
.await
}
@@ -538,19 +644,92 @@ mod manager {
async fn post_remove_network_instances(&self, ids: &[uuid::Uuid]) -> Result<(), String> {
let client_manager = get_client_manager!()?;
client_manager
.post_remove_network_instances_hook(&self.app, ids)
.post_remote_remove_network_instances_hook(&self.app, ids)
.await
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub(super) enum PersistedConfigSource {
User,
Webhook,
#[serde(other)]
#[default]
Legacy,
}
impl PersistedConfigSource {
pub(super) fn from_runtime_source(source: ConfigSource) -> Self {
match source {
ConfigSource::User => Self::User,
ConfigSource::Webhook => Self::Webhook,
}
}
fn merge_persisted(self, incoming: Self) -> Self {
match (self, incoming) {
// Older runtimes report missing source as `user`. Keep the stronger persisted
// ownership until webhook sync or an explicit user save repairs it.
(Self::Webhook, Self::User) | (Self::Legacy, Self::User) => self,
(_, next) => next,
}
}
fn to_runtime_source(self) -> ConfigSource {
match self {
Self::User | Self::Legacy => ConfigSource::User,
Self::Webhook => ConfigSource::Webhook,
}
}
#[cfg(any(test, target_os = "android"))]
fn is_webhook_like(self) -> bool {
matches!(self, Self::Webhook)
}
}
#[derive(Clone)]
pub(super) struct GUIConfig(String, pub(crate) NetworkConfig);
pub(super) struct GUIConfig {
inst_id: String,
pub(crate) config: NetworkConfig,
source: PersistedConfigSource,
}
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub(super) struct StoredGuiConfig {
config: NetworkConfig,
#[serde(default)]
source: PersistedConfigSource,
}
impl GUIConfig {
fn new(inst_id: String, config: NetworkConfig, source: PersistedConfigSource) -> Self {
Self {
inst_id,
config,
source,
}
}
fn into_stored(self) -> StoredGuiConfig {
StoredGuiConfig {
config: self.config,
source: self.source,
}
}
}
impl PersistentConfig<anyhow::Error> for GUIConfig {
fn get_network_inst_id(&self) -> &str {
&self.0
&self.inst_id
}
fn get_network_config(&self) -> Result<NetworkConfig, anyhow::Error> {
Ok(self.1.clone())
Ok(self.config.clone())
}
fn get_network_config_source(&self) -> ConfigSource {
self.source.to_runtime_source()
}
}
@@ -567,13 +746,12 @@ mod manager {
}
fn save_configs(&self, app: &AppHandle) -> anyhow::Result<()> {
let configs: Result<Vec<String>, _> = self
let configs = 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)?;
.map(|entry| entry.value().clone().into_stored())
.collect::<Vec<_>>();
app.emit("save_configs", configs)?;
Ok(())
}
@@ -592,8 +770,14 @@ mod manager {
app: &AppHandle,
inst_id: Uuid,
cfg: NetworkConfig,
source: PersistedConfigSource,
) -> anyhow::Result<()> {
let config = GUIConfig(inst_id.to_string(), cfg);
let source = self
.network_configs
.get(&inst_id)
.map(|existing| existing.source.merge_persisted(source))
.unwrap_or(source);
let config = GUIConfig::new(inst_id.to_string(), cfg, source);
self.network_configs.insert(inst_id, config);
self.save_configs(app)
}
@@ -605,8 +789,14 @@ mod manager {
app: AppHandle,
network_inst_id: Uuid,
network_config: NetworkConfig,
source: ConfigSource,
) -> Result<(), anyhow::Error> {
self.save_config(&app, network_inst_id, network_config)?;
self.save_config(
&app,
network_inst_id,
network_config,
PersistedConfigSource::from_runtime_source(source),
)?;
self.enabled_networks.insert(network_inst_id);
self.save_enabled_networks(&app)?;
Ok(())
@@ -621,7 +811,9 @@ mod manager {
self.network_configs.remove(network_inst_id);
self.enabled_networks.remove(network_inst_id);
}
self.save_configs(&app)
self.save_configs(&app)?;
self.save_enabled_networks(&app)?;
Ok(())
}
async fn update_network_config_state(
@@ -721,17 +913,36 @@ mod manager {
.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())
.filter(|v| !v.config.no_tun())
.filter_map(|c| c.config.instance_id().parse::<uuid::Uuid>().ok())
}
#[cfg(target_os = "android")]
pub fn get_enabled_instances_with_webhook_like_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.config.no_tun())
.filter(|v| v.source.is_webhook_like())
.filter_map(|c| c.config.instance_id().parse::<uuid::Uuid>().ok())
}
#[cfg(target_os = "android")]
pub(super) async fn disable_instances_with_tun(
&self,
app: &AppHandle,
webhook_only: bool,
) -> Result<(), easytier::rpc_service::remote_client::RemoteClientError<anyhow::Error>>
{
let inst_ids: Vec<uuid::Uuid> = self.get_enabled_instances_with_tun_ids().collect();
let inst_ids: Vec<uuid::Uuid> = if webhook_only {
self.get_enabled_instances_with_webhook_like_tun_ids()
.collect()
} else {
self.get_enabled_instances_with_tun_ids().collect()
};
for inst_id in inst_ids {
self.handle_update_network_state(app.clone(), inst_id, true)
.await?;
@@ -752,16 +963,32 @@ mod manager {
&self,
app: &AppHandle,
cfg: &easytier::common::config::TomlConfigLoader,
source: PersistedConfigSource,
) -> Result<(), String> {
let instance_id = cfg.get_id();
app.emit("pre_run_network_instance", instance_id)
app.emit("pre_run_network_instance", instance_id.to_string())
.map_err(|e| e.to_string())?;
#[cfg(target_os = "android")]
if !cfg.get_flags().no_tun {
self.disable_instances_with_tun(app)
.await
.map_err(|e| e.to_string())?;
match source {
PersistedConfigSource::User | PersistedConfigSource::Legacy => {
self.disable_instances_with_tun(app, false)
.await
.map_err(|e| e.to_string())?;
}
PersistedConfigSource::Webhook => {
self.disable_instances_with_tun(app, true)
.await
.map_err(|e| e.to_string())?;
if self.get_enabled_instances_with_tun_ids().next().is_some() {
return Err(
"Android only supports one active TUN network; user-managed VPN remains active"
.to_string(),
);
}
}
}
}
self.storage
@@ -769,6 +996,7 @@ mod manager {
app,
instance_id,
NetworkConfig::new_from_config(cfg).map_err(|e| e.to_string())?,
source,
)
.map_err(|e| e.to_string())?;
@@ -791,20 +1019,21 @@ mod manager {
let app_clone = app.clone();
let instance_id_clone = *instance_id;
tokio::spawn(async move {
let instance_id_str = instance_id_clone.to_string();
loop {
match event_receiver.recv().await {
Ok(easytier::common::global_ctx::GlobalCtxEvent::DhcpIpv4Changed(_, _)) => {
let _ = app_clone.emit("dhcp_ip_changed", instance_id_clone);
let _ = app_clone.emit("dhcp_ip_changed", &instance_id_str);
}
Ok(easytier::common::global_ctx::GlobalCtxEvent::ProxyCidrsUpdated(_, _)) => {
let _ = app_clone.emit("proxy_cidrs_updated", instance_id_clone);
let _ = app_clone.emit("proxy_cidrs_updated", &instance_id_str);
}
Ok(_) => {}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
break;
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
let _ = app_clone.emit("event_lagged", instance_id_clone);
let _ = app_clone.emit("event_lagged", &instance_id_str);
event_receiver = event_receiver.resubscribe();
}
}
@@ -816,20 +1045,29 @@ mod manager {
self.storage.enabled_networks.insert(*instance_id);
app.emit("post_run_network_instance", instance_id)
app.emit("post_run_network_instance", instance_id.to_string())
.map_err(|e| e.to_string())?;
Ok(())
}
pub(super) async fn post_remove_network_instances_hook(
pub(super) async fn post_remote_remove_network_instances_hook(
&self,
app: &AppHandle,
_ids: &[uuid::Uuid],
ids: &[uuid::Uuid],
) -> Result<(), String> {
self.storage
.enabled_networks
.retain(|id| !_ids.contains(id));
.delete_network_configs(app.clone(), ids)
.await
.map_err(|e| e.to_string())?;
self.notify_vpn_stop_if_no_tun(app)?;
Ok(())
}
pub(super) async fn post_stop_network_instances_hook(
&self,
app: &AppHandle,
) -> Result<(), String> {
self.notify_vpn_stop_if_no_tun(app)?;
Ok(())
}
@@ -862,15 +1100,15 @@ mod manager {
pub(super) async fn load_configs(
&self,
app: AppHandle,
configs: Vec<NetworkConfig>,
configs: Vec<StoredGuiConfig>,
enabled_networks: Vec<String>,
) -> anyhow::Result<()> {
self.storage.network_configs.clear();
for cfg in configs {
let instance_id = cfg.instance_id();
for stored in configs {
let instance_id = stored.config.instance_id();
self.storage.network_configs.insert(
instance_id.parse()?,
GUIConfig(instance_id.to_string(), cfg),
GUIConfig::new(instance_id.to_string(), stored.config, stored.source),
);
}
@@ -879,28 +1117,35 @@ mod manager {
.get_rpc_client(app.clone())
.ok_or_else(|| anyhow::anyhow!("RPC client not found"))?;
for id in enabled_networks {
if let Ok(uuid) = id.parse() {
if !self.storage.enabled_networks.contains(&uuid) {
let config = self
.storage
.network_configs
.get(&uuid)
.map(|i| i.value().1.clone());
if config.is_none() {
continue;
}
client
.run_network_instance(
BaseController::default(),
RunNetworkInstanceRequest {
inst_id: None,
config,
overwrite: false,
},
)
.await?;
self.storage.enabled_networks.insert(uuid);
}
if let Ok(uuid) = id.parse()
&& !self.storage.enabled_networks.contains(&uuid)
{
let config = self
.storage
.network_configs
.get(&uuid)
.map(|i| (i.value().config.clone(), i.value().source));
let Some((config, source)) = config else {
continue;
};
let toml_config = config.gen_config()?;
self.pre_run_network_instance_hook(&app, &toml_config, source)
.await
.map_err(|e| anyhow::anyhow!(e))?;
client
.run_network_instance(
BaseController::default(),
RunNetworkInstanceRequest {
inst_id: None,
config: Some(config),
overwrite: false,
source: source.to_runtime_source().to_rpc(),
},
)
.await?;
self.post_run_network_instance_hook(&app, &uuid)
.await
.map_err(|e| anyhow::anyhow!(e))?;
}
}
Ok(())
@@ -926,6 +1171,44 @@ mod manager {
&self.storage
}
}
#[cfg(test)]
mod tests {
use super::{PersistedConfigSource, StoredGuiConfig};
use easytier::proto::api::manage::NetworkConfig;
#[test]
fn stored_gui_config_defaults_missing_source_to_legacy() {
let stored: StoredGuiConfig = serde_json::from_value(serde_json::json!({
"config": NetworkConfig::default(),
}))
.unwrap();
assert_eq!(stored.source, PersistedConfigSource::Legacy);
}
#[test]
fn persisted_source_merge_keeps_legacy_and_webhook_over_ambiguous_user() {
assert_eq!(
PersistedConfigSource::Legacy.merge_persisted(PersistedConfigSource::User),
PersistedConfigSource::Legacy
);
assert_eq!(
PersistedConfigSource::Webhook.merge_persisted(PersistedConfigSource::User),
PersistedConfigSource::Webhook
);
assert_eq!(
PersistedConfigSource::Legacy.merge_persisted(PersistedConfigSource::Webhook),
PersistedConfigSource::Webhook
);
}
#[test]
fn only_webhook_configs_are_webhook_like() {
assert!(!PersistedConfigSource::Legacy.is_webhook_like());
assert!(!PersistedConfigSource::User.is_webhook_like());
assert!(PersistedConfigSource::Webhook.is_webhook_like());
}
}
}
#[cfg(not(target_os = "android"))]
@@ -1014,7 +1297,7 @@ pub fn run_gui() -> std::process::ExitCode {
process::exit(0);
}
utils::setup_panic_handler();
setup_panic_handler();
let mut builder = tauri::Builder::default();
@@ -1053,7 +1336,7 @@ pub fn run_gui() -> std::process::ExitCode {
})
.build()
.map_err(|e| e.to_string())?;
let Ok(_) = utils::init_logger(&config, true) else {
let Ok(_) = log::init(&config, true) else {
return Ok(());
};
+2 -2
View File
@@ -17,7 +17,7 @@
"createUpdaterArtifacts": false
},
"productName": "easytier-gui",
"version": "2.5.0",
"version": "2.6.4",
"identifier": "com.kkrainbow.easytier",
"plugins": {
"shell": {
@@ -36,4 +36,4 @@
"csp": null
}
}
}
}
+6
View File
@@ -43,6 +43,7 @@ declare global {
const isWebClientConnected: typeof import('./composables/backend')['isWebClientConnected']
const listNetworkInstanceIds: typeof import('./composables/backend')['listNetworkInstanceIds']
const listenGlobalEvents: typeof import('./composables/event')['listenGlobalEvents']
const loadLastNetworkInstanceId: typeof import('./composables/config')['loadLastNetworkInstanceId']
const loadMode: typeof import('./composables/mode')['loadMode']
const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
@@ -76,6 +77,7 @@ declare global {
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const runNetworkInstance: typeof import('./composables/backend')['runNetworkInstance']
const saveLastNetworkInstanceId: typeof import('./composables/config')['saveLastNetworkInstanceId']
const saveMode: typeof import('./composables/mode')['saveMode']
const saveNetworkConfig: typeof import('./composables/backend')['saveNetworkConfig']
const sendConfigs: typeof import('./composables/backend')['sendConfigs']
@@ -91,6 +93,7 @@ declare global {
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const storeToRefs: typeof import('pinia')['storeToRefs']
const syncMobileVpnService: typeof import('./composables/mobile_vpn')['syncMobileVpnService']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
@@ -165,6 +168,7 @@ declare module 'vue' {
readonly isWebClientConnected: UnwrapRef<typeof import('./composables/backend')['isWebClientConnected']>
readonly listNetworkInstanceIds: UnwrapRef<typeof import('./composables/backend')['listNetworkInstanceIds']>
readonly listenGlobalEvents: UnwrapRef<typeof import('./composables/event')['listenGlobalEvents']>
readonly loadLastNetworkInstanceId: UnwrapRef<typeof import('./composables/config')['loadLastNetworkInstanceId']>
readonly loadMode: UnwrapRef<typeof import('./composables/mode')['loadMode']>
readonly mapActions: UnwrapRef<typeof import('pinia')['mapActions']>
readonly mapGetters: UnwrapRef<typeof import('pinia')['mapGetters']>
@@ -198,6 +202,7 @@ declare module 'vue' {
readonly ref: UnwrapRef<typeof import('vue')['ref']>
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
readonly runNetworkInstance: UnwrapRef<typeof import('./composables/backend')['runNetworkInstance']>
readonly saveLastNetworkInstanceId: UnwrapRef<typeof import('./composables/config')['saveLastNetworkInstanceId']>
readonly saveMode: UnwrapRef<typeof import('./composables/mode')['saveMode']>
readonly saveNetworkConfig: UnwrapRef<typeof import('./composables/backend')['saveNetworkConfig']>
readonly sendConfigs: UnwrapRef<typeof import('./composables/backend')['sendConfigs']>
@@ -213,6 +218,7 @@ declare module 'vue' {
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
readonly storeToRefs: UnwrapRef<typeof import('pinia')['storeToRefs']>
readonly syncMobileVpnService: UnwrapRef<typeof import('./composables/mobile_vpn')['syncMobileVpnService']>
readonly toRaw: UnwrapRef<typeof import('vue')['toRaw']>
readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
+82 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, watch, onMounted, ref } from 'vue';
import type { Mode, ServiceMode, RemoteMode } from '~/composables/mode';
import type { Mode, ServiceMode, RemoteMode, NormalMode } from '~/composables/mode';
import { appConfigDir, appLogDir } from '@tauri-apps/api/path';
import { join } from '@tauri-apps/api/path';
import { getServiceStatus, type ServiceStatus } from '~/composables/backend';
@@ -15,6 +15,14 @@ const defaultLogDir = ref('')
const serviceStatus = ref<ServiceStatus>('NotInstalled')
const isServiceStatusLoaded = ref(false)
function normalizeRpcListenPort(port: unknown): number {
const defaultPort = 15999
const numericPort = typeof port === 'number' ? port : Number.parseInt(String(port ?? ''), 10)
if (Number.isNaN(numericPort))
return defaultPort
return Math.min(65535, Math.max(1, Math.floor(numericPort)))
}
onMounted(async () => {
defaultConfigDir.value = await join(await appConfigDir(), 'config.d')
defaultLogDir.value = await appLogDir()
@@ -26,6 +34,43 @@ const modeOptions = computed(() => [
{ label: t('mode.remote'), value: 'remote' },
]);
const normalMode = computed({
get: () => model.value.mode === 'normal' ? model.value as NormalMode : undefined,
set: (value) => {
if (value) {
model.value = value
}
}
})
const rpcListenOptions = computed(() => [
{ label: t('web.common.disable'), value: false },
{ label: t('web.common.enable'), value: true },
])
const rpcListenEnabled = computed<boolean>({
get: () => !!normalMode.value?.enable_rpc_port_listen,
set: (value) => {
if (!normalMode.value)
return
normalMode.value.enable_rpc_port_listen = value
},
})
const rpcListenPort = computed<string>({
get: () => String(normalMode.value?.rpc_listen_port ?? 15999),
set: (value) => {
if (!normalMode.value)
return
const trimmed = value.trim()
if (trimmed === '')
return
if (!/^\d+$/.test(trimmed))
return
normalMode.value.rpc_listen_port = Number.parseInt(trimmed, 10)
},
})
const serviceMode = computed({
get: () => model.value.mode === 'service' ? model.value as ServiceMode : undefined,
set: (value) => {
@@ -57,6 +102,24 @@ const statusColorClass = computed(() => {
}
})
watch(() => [normalMode.value?.enable_rpc_port_listen, normalMode.value?.rpc_listen_port], ([enabled, port]) => {
if (!normalMode.value)
return
if (!enabled) {
normalMode.value.rpc_portal = undefined
return
}
const normalizedPort = normalizeRpcListenPort(port)
if (normalMode.value.rpc_listen_port !== normalizedPort)
normalMode.value.rpc_listen_port = normalizedPort
const desiredPortal = `tcp://0.0.0.0:${normalizedPort}`
if (normalMode.value.rpc_portal !== desiredPortal)
normalMode.value.rpc_portal = desiredPortal
}, { immediate: true })
watch(() => model.value.mode, async (newMode, oldMode) => {
if (newMode === oldMode)
return
@@ -69,8 +132,12 @@ watch(() => model.value.mode, async (newMode, oldMode) => {
const oldModelValue = { ...model.value }
if (newMode === 'normal') {
const portal = normalMode.value?.rpc_portal?.trim()
model.value = {
...oldModelValue,
rpc_portal: portal || undefined,
enable_rpc_port_listen: normalMode.value?.enable_rpc_port_listen,
rpc_listen_port: normalMode.value?.rpc_listen_port,
mode: 'normal',
}
}
@@ -113,6 +180,20 @@ watch(() => model.value.mode, async (newMode, oldMode) => {
{{ t('mode.remote_description') }}
</div>
<div v-if="normalMode" class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<label for="rpc-listen-toggle">{{ t('mode.enable_rpc_tcp_listen') }}</label>
<SelectButton id="rpc-listen-toggle" v-model="rpcListenEnabled" :options="rpcListenOptions" option-label="label"
option-value="value" />
</div>
<div v-if="rpcListenEnabled" class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<label for="rpc-listen-port">{{ t('mode.rpc_listen_port') }}</label>
<InputText id="rpc-listen-port" v-model="rpcListenPort" class="flex-1" inputmode="numeric" />
</div>
</div>
</div>
<div v-if="serviceMode" class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<label for="config-dir">{{ t('mode.config_dir') }}</label>
+53 -11
View File
@@ -1,11 +1,12 @@
import { invoke } from '@tauri-apps/api/core'
import { Api, type NetworkTypes } from 'easytier-frontend-lib'
import { Api, NetworkTypes } from 'easytier-frontend-lib'
import { GetNetworkMetasResponse } from 'node_modules/easytier-frontend-lib/dist/modules/api'
type NetworkConfig = NetworkTypes.NetworkConfig
type ValidateConfigResponse = Api.ValidateConfigResponse
type ListNetworkInstanceIdResponse = Api.ListNetworkInstanceIdResponse
type ConfigSource = 'user' | 'webhook' | 'legacy'
interface ServiceOptions {
config_dir: string
rpc_portal: string
@@ -16,16 +17,50 @@ interface ServiceOptions {
export type ServiceStatus = "Running" | "Stopped" | "NotInstalled"
interface StoredGuiConfig {
config: NetworkConfig
source: ConfigSource
}
function parseStoredConfigs(raw: string | null): StoredGuiConfig[] {
const parsed: unknown = JSON.parse(raw || '[]')
if (!Array.isArray(parsed)) {
return []
}
return parsed.flatMap((entry): StoredGuiConfig[] => {
if (entry && typeof entry === 'object' && 'config' in entry) {
const { config, source } = entry as {
config?: NetworkConfig
source?: ConfigSource
}
if (!config) {
return []
}
return [{
config: NetworkTypes.normalizeNetworkConfig(config),
source: source === 'user' || source === 'webhook' ? source : 'legacy',
}]
}
return [{
config: NetworkTypes.normalizeNetworkConfig(entry as NetworkConfig),
source: 'legacy',
}]
})
}
export async function parseNetworkConfig(cfg: NetworkConfig) {
return invoke<string>('parse_network_config', { cfg })
return invoke<string>('parse_network_config', { cfg: NetworkTypes.toBackendNetworkConfig(cfg) })
}
export async function generateNetworkConfig(tomlConfig: string) {
return invoke<NetworkConfig>('generate_network_config', { tomlConfig })
const config = await invoke<NetworkConfig>('generate_network_config', { tomlConfig })
return NetworkTypes.normalizeNetworkConfig(config)
}
export async function runNetworkInstance(cfg: NetworkConfig, save: boolean) {
return invoke('run_network_instance', { cfg, save })
return invoke('run_network_instance', { cfg: NetworkTypes.toBackendNetworkConfig(cfg), save })
}
export async function collectNetworkInfo(instanceId: string) {
@@ -57,20 +92,27 @@ export async function updateNetworkConfigState(instanceId: string, disabled: boo
}
export async function saveNetworkConfig(cfg: NetworkConfig) {
return await invoke('save_network_config', { cfg })
return await invoke('save_network_config', { cfg: NetworkTypes.toBackendNetworkConfig(cfg) })
}
export async function validateConfig(cfg: NetworkConfig) {
return await invoke<ValidateConfigResponse>('validate_config', { cfg })
return await invoke<ValidateConfigResponse>('validate_config', { cfg: NetworkTypes.toBackendNetworkConfig(cfg) })
}
export async function getConfig(instanceId: string) {
return await invoke<NetworkConfig>('get_config', { instanceId })
const config = await invoke<NetworkConfig>('get_config', { instanceId })
return NetworkTypes.normalizeNetworkConfig(config)
}
export async function sendConfigs(enabledNetworks: string[]) {
let networkList: NetworkConfig[] = JSON.parse(localStorage.getItem('networkList') || '[]');
return await invoke('load_configs', { configs: networkList, enabledNetworks })
const networkList = parseStoredConfigs(localStorage.getItem('networkList'))
return await invoke('load_configs', {
configs: networkList.map(({ config, source }) => ({
config: NetworkTypes.toBackendNetworkConfig(config),
source,
})),
enabledNetworks
})
}
export async function getNetworkMetas(instanceIds: string[]) {
@@ -89,8 +131,8 @@ export async function getServiceStatus() {
return await invoke<ServiceStatus>('get_service_status')
}
export async function initRpcConnection(url?: string) {
return await invoke('init_rpc_connection', { url })
export async function initRpcConnection(isNormalMode: boolean, url?: string) {
return await invoke('init_rpc_connection', { isNormalMode, url })
}
export async function isClientRunning() {
+20
View File
@@ -0,0 +1,20 @@
/**
*
*
*/
/**
* 使 ID
* @param instanceId ID
*/
export function saveLastNetworkInstanceId(instanceId: string) {
localStorage.setItem('last_network_instance_id', instanceId)
}
/**
* 使 ID
* @returns 使 ID null
*/
export function loadLastNetworkInstanceId(): string | null {
return localStorage.getItem('last_network_instance_id')
}
+60 -16
View File
@@ -1,6 +1,12 @@
import { Event, listen } from "@tauri-apps/api/event";
import { type } from "@tauri-apps/plugin-os";
import { NetworkTypes } from "easytier-frontend-lib"
import { Utils } from "easytier-frontend-lib";
interface StoredGuiConfig {
config: NetworkTypes.NetworkConfig
source?: 'user' | 'webhook' | 'legacy'
}
const EVENTS = Object.freeze({
SAVE_CONFIGS: 'save_configs',
@@ -12,44 +18,82 @@ const EVENTS = Object.freeze({
EVENT_LAGGED: 'event_lagged',
});
function onSaveConfigs(event: Event<NetworkTypes.NetworkConfig[]>) {
function onSaveConfigs(event: Event<StoredGuiConfig[]>) {
console.log(`Received event '${EVENTS.SAVE_CONFIGS}': ${event.payload}`);
localStorage.setItem('networkList', JSON.stringify(event.payload));
localStorage.setItem(
'networkList',
JSON.stringify(event.payload.map(({ config, source }) => ({
config: NetworkTypes.normalizeNetworkConfig(config),
source: source ?? 'legacy',
}))),
);
}
async function onPreRunNetworkInstance(event: Event<string>) {
function normalizeInstanceIdPayload(payload: unknown): string {
if (typeof payload === 'string') {
return payload
}
if (payload && typeof payload === 'object') {
const uuid = payload as Partial<Utils.UUID>
if (
typeof uuid.part1 === 'number'
&& typeof uuid.part2 === 'number'
&& typeof uuid.part3 === 'number'
&& typeof uuid.part4 === 'number'
) {
return Utils.UuidToStr(uuid as Utils.UUID)
}
}
if (payload == null) {
return ''
}
const fallback = String(payload)
return fallback === '[object Object]' ? '' : fallback
}
async function onPreRunNetworkInstance(event: Event<unknown>) {
const instanceId = normalizeInstanceIdPayload(event.payload)
console.log(`Received event '${EVENTS.PRE_RUN_NETWORK_INSTANCE}', raw payload:`, event.payload, 'normalized:', instanceId)
if (type() === 'android') {
await prepareVpnService(event.payload);
await prepareVpnService(instanceId);
}
}
async function onPostRunNetworkInstance(event: Event<string>) {
async function onPostRunNetworkInstance(event: Event<unknown>) {
const instanceId = normalizeInstanceIdPayload(event.payload)
console.log(`Received event '${EVENTS.POST_RUN_NETWORK_INSTANCE}', raw payload:`, event.payload, 'normalized:', instanceId)
if (type() === 'android') {
await onNetworkInstanceChange(event.payload);
await onNetworkInstanceChange(instanceId);
}
}
async function onVpnServiceStop(event: Event<string>) {
await onNetworkInstanceChange(event.payload);
async function onVpnServiceStop(event: Event<unknown>) {
console.log(`Received event '${EVENTS.VPN_SERVICE_STOP}', raw payload:`, event.payload)
await syncMobileVpnService();
}
async function onDhcpIpChanged(event: Event<string>) {
console.log(`Received event '${EVENTS.DHCP_IP_CHANGED}' for instance: ${event.payload}`);
async function onDhcpIpChanged(event: Event<unknown>) {
const instanceId = normalizeInstanceIdPayload(event.payload)
console.log(`Received event '${EVENTS.DHCP_IP_CHANGED}' for instance: ${instanceId}`);
if (type() === 'android') {
await onNetworkInstanceChange(event.payload);
await onNetworkInstanceChange(instanceId);
}
}
async function onProxyCidrsUpdated(event: Event<string>) {
console.log(`Received event '${EVENTS.PROXY_CIDRS_UPDATED}' for instance: ${event.payload}`);
async function onProxyCidrsUpdated(event: Event<unknown>) {
const instanceId = normalizeInstanceIdPayload(event.payload)
console.log(`Received event '${EVENTS.PROXY_CIDRS_UPDATED}' for instance: ${instanceId}`);
if (type() === 'android') {
await onNetworkInstanceChange(event.payload);
await onNetworkInstanceChange(instanceId);
}
}
async function onEventLagged(event: Event<string>) {
async function onEventLagged(event: Event<unknown>) {
if (type() === 'android') {
await onNetworkInstanceChange(event.payload);
await onNetworkInstanceChange(normalizeInstanceIdPayload(event.payload));
}
}
+140 -26
View File
@@ -1,7 +1,7 @@
import type { NetworkTypes } from 'easytier-frontend-lib'
import { addPluginListener } from '@tauri-apps/api/core'
import { Utils } from 'easytier-frontend-lib'
import { prepare_vpn, start_vpn, stop_vpn } from 'tauri-plugin-vpnservice-api'
import { get_vpn_status, prepare_vpn, start_vpn, stop_vpn } from 'tauri-plugin-vpnservice-api'
type Route = NetworkTypes.Route
@@ -24,6 +24,53 @@ const curVpnStatus: vpnStatus = {
dns: undefined,
}
async function requestVpnPermission() {
console.log('prepare vpn')
const prepare_ret = await prepare_vpn()
console.log('prepare vpn', JSON.stringify((prepare_ret)))
if (prepare_ret?.errorMsg?.length) {
throw new Error(prepare_ret.errorMsg)
}
const granted = prepare_ret?.granted ?? true
if (!granted) {
console.info('vpn permission request was denied or dismissed')
}
return granted
}
function resetVpnConfigStatus() {
curVpnStatus.ipv4Addr = undefined
curVpnStatus.ipv4Cidr = undefined
curVpnStatus.routes = []
curVpnStatus.dns = undefined
}
function syncVpnStatusFromNative(status: Awaited<ReturnType<typeof get_vpn_status>>) {
curVpnStatus.running = status?.running ?? false
if (!curVpnStatus.running) {
resetVpnConfigStatus()
return
}
const ipv4WithCidr = status?.ipv4Addr
if (ipv4WithCidr?.length) {
const [ipv4Addr, cidr] = ipv4WithCidr.split('/')
curVpnStatus.ipv4Addr = ipv4Addr
const parsedCidr = Number(cidr)
curVpnStatus.ipv4Cidr = Number.isInteger(parsedCidr) ? parsedCidr : undefined
}
else {
curVpnStatus.ipv4Addr = undefined
curVpnStatus.ipv4Cidr = undefined
}
curVpnStatus.routes = [...(status?.routes ?? [])]
curVpnStatus.dns = status?.dns ?? undefined
}
async function waitVpnStatus(target_status: boolean, timeout_sec: number) {
const start_time = Date.now()
while (curVpnStatus.running !== target_status) {
@@ -34,18 +81,19 @@ async function waitVpnStatus(target_status: boolean, timeout_sec: number) {
}
}
async function doStopVpn() {
if (!curVpnStatus.running) {
async function doStopVpn(force = false) {
const wasRunning = curVpnStatus.running
if (!force && !wasRunning) {
return
}
console.log('stop vpn')
const stop_ret = await stop_vpn()
console.log('stop vpn', JSON.stringify((stop_ret)))
await waitVpnStatus(false, 3)
if (wasRunning) {
await waitVpnStatus(false, 3)
}
curVpnStatus.ipv4Addr = undefined
curVpnStatus.routes = []
curVpnStatus.dns = undefined
resetVpnConfigStatus()
}
async function doStartVpn(ipv4Addr: string, cidr: number, routes: string[], dns?: string) {
@@ -54,19 +102,32 @@ async function doStartVpn(ipv4Addr: string, cidr: number, routes: string[], dns?
}
console.log('start vpn service', ipv4Addr, cidr, routes, dns)
const start_ret = await start_vpn({
const request = {
ipv4Addr: `${ipv4Addr}/${cidr}`,
routes,
dns,
disallowedApplications: ['com.kkrainbow.easytier'],
mtu: 1300,
})
}
let start_ret = await start_vpn(request)
console.log('start vpn response', JSON.stringify(start_ret))
if (start_ret?.errorMsg === 'need_prepare') {
const granted = await requestVpnPermission()
if (!granted) {
throw new Error('vpn_permission_denied')
}
start_ret = await start_vpn(request)
console.log('start vpn retry response', JSON.stringify(start_ret))
}
if (start_ret?.errorMsg?.length) {
throw new Error(start_ret.errorMsg)
}
await waitVpnStatus(true, 3)
curVpnStatus.ipv4Addr = ipv4Addr
curVpnStatus.ipv4Cidr = cidr
curVpnStatus.routes = routes
curVpnStatus.dns = dns
}
@@ -75,13 +136,16 @@ async function onVpnServiceStart(payload: any) {
console.log('vpn service start', JSON.stringify(payload))
curVpnStatus.running = true
if (payload.fd) {
setTunFd(payload.fd)
await setTunFd(payload.fd).catch((e) => {
console.error('set tun fd failed', e)
})
}
}
async function onVpnServiceStop(payload: any) {
console.log('vpn service stop', JSON.stringify(payload))
curVpnStatus.running = false
resetVpnConfigStatus()
}
async function registerVpnServiceListener() {
@@ -135,15 +199,25 @@ export async function onNetworkInstanceChange(instanceId: string) {
}
if (!instanceId) {
await doStopVpn()
console.warn('vpn service skipped because instance id is empty')
if (curVpnStatus.running) {
await doStopVpn()
}
return
}
const config = await getConfig(instanceId)
console.log('vpn service loaded config', instanceId, JSON.stringify({
no_tun: config.no_tun,
dhcp: config.dhcp,
enable_magic_dns: config.enable_magic_dns,
}))
if (config.no_tun) {
console.log('vpn service skipped because no_tun is enabled', instanceId)
return
}
const curNetworkInfo = (await collectNetworkInfo(instanceId)).info.map[instanceId]
if (!curNetworkInfo || curNetworkInfo?.error_msg?.length) {
console.warn('vpn service skipped because network info is unavailable', instanceId, curNetworkInfo?.error_msg)
await doStopVpn()
return
}
@@ -170,27 +244,39 @@ export async function onNetworkInstanceChange(instanceId: string) {
const routes = getRoutesForVpn(curNetworkInfo?.routes, config)
const dns = config.enable_magic_dns ? '100.100.100.101' : undefined;
const dns = config.enable_magic_dns ? '100.100.100.101' : undefined
const ipChanged = virtual_ip !== curVpnStatus.ipv4Addr
const cidrChanged = network_length !== curVpnStatus.ipv4Cidr
const routesChanged = JSON.stringify(routes) !== JSON.stringify(curVpnStatus.routes)
const dnsChanged = dns != curVpnStatus.dns
const configChanged = ipChanged || cidrChanged || routesChanged || dnsChanged
const shouldStartVpn = !curVpnStatus.running
if (ipChanged || routesChanged || dnsChanged) {
if (shouldStartVpn || configChanged) {
console.info('vpn service virtual ip changed', JSON.stringify(curVpnStatus), virtual_ip)
try {
await doStopVpn()
}
catch (e) {
console.error(e)
if (curVpnStatus.running) {
try {
await doStopVpn()
}
catch (e) {
console.error(e)
}
}
try {
await doStartVpn(virtual_ip, network_length, routes, dns)
}
catch (e) {
console.error('start vpn service failed, stop all other network insts.', e)
await runNetworkInstance(config, true); //on android config should always be saved
if (e instanceof Error && e.message === 'need_prepare') {
console.info('vpn permission is required before starting the Android VPN service')
return
}
if (e instanceof Error && e.message === 'vpn_permission_denied') {
console.info('vpn permission request was denied or dismissed')
return
}
console.error('start vpn service failed', e)
}
}
}
@@ -202,6 +288,22 @@ async function isNoTunEnabled(instanceId: string | undefined) {
return (await getConfig(instanceId)).no_tun ?? false
}
async function findRunningTunInstanceId() {
const instanceIds = await listNetworkInstanceIds()
const runningIds = instanceIds.running_inst_ids.map(Utils.UuidToStr)
console.log('vpn service sync running instances', JSON.stringify(runningIds))
for (const instanceId of runningIds) {
if (await isNoTunEnabled(instanceId)) {
continue
}
return instanceId
}
return undefined
}
export async function initMobileVpnService() {
await registerVpnServiceListener()
}
@@ -210,10 +312,22 @@ export async function prepareVpnService(instanceId: string) {
if (await isNoTunEnabled(instanceId)) {
return
}
console.log('prepare vpn')
const prepare_ret = await prepare_vpn()
console.log('prepare vpn', JSON.stringify((prepare_ret)))
if (prepare_ret?.errorMsg?.length) {
throw new Error(prepare_ret.errorMsg)
}
await requestVpnPermission()
}
export async function syncMobileVpnService() {
syncVpnStatusFromNative(await get_vpn_status())
const instanceId = await findRunningTunInstanceId()
if (instanceId) {
console.log('vpn service sync selected instance', instanceId)
await onNetworkInstanceChange(instanceId)
return
}
if (dhcpPollingTimer) {
clearTimeout(dhcpPollingTimer)
dhcpPollingTimer = null
}
await doStopVpn(true)
}
+6 -1
View File
@@ -4,8 +4,12 @@ export interface WebClientConfig {
config_server_url?: string
}
interface NormalMode extends WebClientConfig {
export interface NormalMode extends WebClientConfig {
mode: 'normal'
// if not provided will use ring tunnel rpc server
rpc_portal?: string
enable_rpc_port_listen?: boolean
rpc_listen_port?: number
}
export interface ServiceMode extends WebClientConfig {
@@ -14,6 +18,7 @@ export interface ServiceMode extends WebClientConfig {
rpc_portal: string
file_log_level: 'off' | 'warn' | 'info' | 'debug' | 'trace'
file_log_dir: string
installed_core_version?: string
}
export interface RemoteMode {
+65 -25
View File
@@ -9,12 +9,14 @@ import { exit } from '@tauri-apps/plugin-process'
import { I18nUtils, RemoteManagement, Utils } from "easytier-frontend-lib"
import type { MenuItem } from 'primevue/menuitem'
import { useTray } from '~/composables/tray'
import { initMobileVpnService } from '~/composables/mobile_vpn'
import { GUIRemoteClient } from '~/modules/api'
import { useToast, useConfirm } from 'primevue'
import { loadMode, saveMode, WebClientConfig, type Mode } from '~/composables/mode'
import { saveLastNetworkInstanceId, loadLastNetworkInstanceId } from '~/composables/config'
import ModeSwitcher from '~/components/ModeSwitcher.vue'
import { getServiceStatus } from '~/composables/backend'
import { getEasytierVersion, getServiceStatus } from '~/composables/backend'
const { t, locale } = useI18n()
const confirm = useConfirm()
@@ -83,6 +85,20 @@ async function onUninstallService() {
});
}
function stripModeMetadata(mode: Mode) {
if (mode.mode !== 'service') {
return mode
}
const serviceConfig = { ...mode }
delete serviceConfig.installed_core_version
return serviceConfig
}
function modeConfigChanged(next: Mode) {
return JSON.stringify(stripModeMetadata(next)) !== JSON.stringify(stripModeMetadata(currentMode.value))
}
async function onStopService() {
isModeSaving.value = true
manualDisconnect.value = true
@@ -132,13 +148,14 @@ async function initWithMode(mode: Mode) {
}
url = mode.remote_rpc_address
break;
case 'service':
case 'service': {
if (!mode.config_dir || !mode.file_log_dir || !mode.file_log_level || !mode.rpc_portal) {
toast.add({ severity: 'error', summary: t('error'), detail: t('mode.service_config_empty'), life: 10000 })
return initWithMode({ ...mode, mode: 'normal' });
}
let serviceStatus = await getServiceStatus()
if (serviceStatus === "NotInstalled" || JSON.stringify(mode) !== JSON.stringify(currentMode.value)) {
const coreVersion = await getEasytierVersion()
if (serviceStatus === "NotInstalled" || modeConfigChanged(mode) || mode.installed_core_version !== coreVersion) {
mode.config_server_url = mode.config_server_url || undefined
await initService({
config_dir: mode.config_dir,
@@ -147,6 +164,7 @@ async function initWithMode(mode: Mode) {
rpc_portal: mode.rpc_portal,
config_server: mode.config_server_url,
})
mode.installed_core_version = coreVersion
serviceStatus = await getServiceStatus()
}
if (serviceStatus === "Stopped") {
@@ -155,13 +173,24 @@ async function initWithMode(mode: Mode) {
url = "tcp://" + mode.rpc_portal.replace("0.0.0.0", "127.0.0.1")
retrys = 5
break;
}
case 'normal':
url = mode.rpc_portal;
break;
}
for (let i = 0; i < retrys; i++) {
try {
await connectRpcClient(url)
await connectRpcClient(mode.mode === 'normal', url)
break;
} catch (e) {
if (i === retrys - 1) {
const errMsg = e instanceof Error ? e.message : String(e)
toast.add({
severity: 'error',
summary: t('error'),
detail: t('mode.rpc_connection_failed', { error: errMsg }),
life: 1000,
})
throw e;
}
console.error("Error connecting rpc client, retrying...", e)
@@ -178,9 +207,25 @@ async function initWithMode(mode: Mode) {
clientRunning.value = await isClientRunning()
}
onMounted(() => {
onMounted(async () => {
const cleanupFns: Array<() => void> = []
if (type() === 'android') {
try {
await initMobileVpnService()
console.error("easytier init vpn service done")
} catch (e: any) {
console.error("easytier init vpn service failed", e)
}
}
cleanupFns.push(await listenGlobalEvents())
currentMode.value = loadMode()
initWithMode(currentMode.value);
await initWithMode(currentMode.value);
onUnmounted(() => {
cleanupFns.forEach(unlisten => unlisten())
})
});
useTray(true)
@@ -190,6 +235,12 @@ const remoteClient = computed(() => new GUIRemoteClient());
const instanceId = ref<string | undefined>(undefined);
const clientRunning = ref(false);
watch(instanceId, (newVal) => {
if (newVal) {
saveLastNetworkInstanceId(newVal);
}
});
watch(clientRunning, async (newVal, oldVal) => {
if (!newVal && oldVal) {
if (manualDisconnect.value) {
@@ -197,6 +248,11 @@ watch(clientRunning, async (newVal, oldVal) => {
return
}
await reconnectClient()
} else if (newVal && !oldVal) {
const lastInstanceId = loadLastNetworkInstanceId();
if (lastInstanceId) {
instanceId.value = lastInstanceId;
}
}
})
@@ -320,27 +376,11 @@ const setting_menu_items: Ref<MenuItem[]> = ref([
},
])
async function connectRpcClient(url?: string) {
await initRpcConnection(url)
console.log("easytier rpc connection established")
async function connectRpcClient(isNormalMode: boolean, url?: string) {
await initRpcConnection(isNormalMode, url)
console.log("easytier rpc connection established, isNormalMode: ", isNormalMode)
}
onMounted(async () => {
if (type() === 'android') {
try {
await initMobileVpnService()
console.error("easytier init vpn service done")
} catch (e: any) {
console.error("easytier init vpn service failed", e)
}
}
const unlisten = await listenGlobalEvents()
onUnmounted(() => {
unlisten()
})
})
async function openConfigServerDialog() {
editingMode.value = JSON.parse(JSON.stringify(loadMode()))
configServerDialogVisible.value = true
+1 -2
View File
@@ -2,13 +2,12 @@
name = "easytier-rpc-build"
description = "Protobuf RPC Service Generator for EasyTier"
version = "0.1.0"
edition = "2021"
edition.workspace = true
homepage = "https://github.com/EasyTier/EasyTier"
repository = "https://github.com/EasyTier/EasyTier"
authors = ["kkrainbow"]
keywords = ["vpn", "p2p", "network", "easytier"]
categories = ["network-programming", "command-line-utilities"]
rust-version = "1.89.0"
license-file = "LICENSE"
readme = "README.md"
+70 -1
View File
@@ -29,6 +29,7 @@ impl prost_build::ServiceGenerator for ServiceGenerator {
let method_descriptor_name = format!("{}MethodDescriptor", service.name);
let mut trait_methods = String::new();
let mut weak_impl_methods = String::new();
let mut enum_methods = String::new();
let mut list_enum_methods = String::new();
let mut client_methods = String::new();
@@ -40,6 +41,8 @@ impl prost_build::ServiceGenerator for ServiceGenerator {
let mut match_output_type_methods = String::new();
let mut match_output_proto_type_methods = String::new();
let mut match_handle_methods = String::new();
// generate trait default method Xxx::json_call_method match branch
let mut match_trait_json_methods = String::new();
let mut match_method_try_from = String::new();
@@ -66,6 +69,21 @@ impl prost_build::ServiceGenerator for ServiceGenerator {
)
.unwrap();
writeln!(
weak_impl_methods,
r#" async fn {method_name}(&self, ctrl: Self::Controller, input: {input_type}) -> {namespace}::error::Result<{output_type}> {{
let Some(service) = self.upgrade() else {{
return Err({namespace}::error::Error::Shutdown);
}};
service.{method_name}(ctrl, input).await
}}"#,
method_name = method.name,
input_type = method.input_type,
output_type = method.output_type,
namespace = NAMESPACE,
)
.unwrap();
ServiceGenerator::write_comments(&mut enum_methods, 4, &method.comments).unwrap();
writeln!(
enum_methods,
@@ -164,6 +182,22 @@ impl prost_build::ServiceGenerator for ServiceGenerator {
namespace = NAMESPACE,
)
.unwrap();
write!(
match_trait_json_methods,
r#" "{name}" | "{proto_name}" => {{
let req: {input_type} = ::serde_json::from_value(json).map_err(|e| {namespace}::error::Error::MalformatRpcPacket(format!("json error: {{}}", e)))?;
let resp = self.{typed_method}(ctrl, req).await?;
Ok(::serde_json::to_value(resp).map_err(|e| {namespace}::error::Error::MalformatRpcPacket(format!("json error: {{}}", e)))?)
}}
"#,
name = method.name,
proto_name = method.proto_name,
input_type = method.input_type,
typed_method = method.name,
namespace = NAMESPACE,
)
.unwrap();
}
ServiceGenerator::write_comments(&mut buf, 0, &service.comments).unwrap();
@@ -176,6 +210,29 @@ pub trait {name} {{
type Controller: {namespace}::controller::Controller;
{trait_methods}
async fn json_call_method(
&self,
ctrl: Self::Controller,
method_name: &str,
json: ::serde_json::Value,
) -> {namespace}::error::Result<::serde_json::Value> {{
match method_name {{
{match_trait_json_methods}
_ => Err({namespace}::error::Error::InvalidMethodIndex(0, method_name.to_string())),
}}
}}
}}
#[async_trait::async_trait]
impl<T> {name} for ::std::sync::Weak<T>
where
T: Send + Sync + 'static,
::std::sync::Arc<T>: {name},
{{
type Controller = <::std::sync::Arc<T> as {name}>::Controller;
{weak_impl_methods}
}}
/// A service descriptor for a `{name}`.
@@ -235,7 +292,7 @@ impl<C: {namespace}::controller::Controller> Clone for {client_name}Factory<C> {
impl<C> {namespace}::__rt::RpcClientFactory for {client_name}Factory<C> where C: {namespace}::controller::Controller {{
type Descriptor = {descriptor_name};
type ClientImpl = Box<dyn {name}<Controller = C> + Send + 'static>;
type ClientImpl = Box<dyn {name}<Controller = C> + Send + Sync + 'static>;
type Controller = C;
fn new(handler: impl {namespace}::handler::Handler<Descriptor = Self::Descriptor, Controller = Self::Controller>) -> Self::ClientImpl {{
@@ -250,6 +307,16 @@ impl<C> {namespace}::__rt::RpcClientFactory for {client_name}Factory<C> where C:
#[derive(Clone, Debug)]
pub struct {server_name}<A>(A) where A: {name} + Clone + Send + 'static;
impl<T> {server_name}<::std::sync::Weak<T>>
where
T: Send + Sync + 'static,
::std::sync::Arc<T>: {name},
{{
pub fn new_arc(service: ::std::sync::Arc<T>) -> {server_name}<::std::sync::Weak<T>> {{
{server_name}(::std::sync::Arc::downgrade(&service))
}}
}}
impl<A> {server_name}<A> where A: {name} + Clone + Send + 'static {{
/// Creates a new server instance that dispatches all calls to the supplied service.
pub fn new(service: A) -> {server_name}<A> {{
@@ -345,6 +412,7 @@ impl {namespace}::descriptor::MethodDescriptor for {method_descriptor_name} {{
proto_name = service.proto_name,
package = service.package,
trait_methods = trait_methods,
weak_impl_methods = weak_impl_methods,
enum_methods = enum_methods,
list_enum_methods = list_enum_methods,
client_own_methods = client_own_methods,
@@ -356,6 +424,7 @@ impl {namespace}::descriptor::MethodDescriptor for {method_descriptor_name} {{
match_output_type_methods = match_output_type_methods,
match_output_proto_type_methods = match_output_proto_type_methods,
match_handle_methods = match_handle_methods,
match_trait_json_methods = match_trait_json_methods,
namespace = NAMESPACE,
).unwrap();
}
+11 -9
View File
@@ -1,7 +1,7 @@
[package]
name = "easytier-web"
version = "2.5.0"
edition = "2021"
version = "2.6.4"
edition.workspace = true
description = "Config server for easytier. easytier-core gets config from this and web frontend use it as restful api server."
[dependencies]
@@ -10,6 +10,7 @@ tracing = { version = "0.1", features = ["log"] }
anyhow = { version = "1.0" }
thiserror = "1.0"
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["rt"] }
dashmap = "6.1"
url = "2.2"
async-trait = "0.1"
@@ -63,16 +64,17 @@ uuid = { version = "1.5.0", features = [
] }
chrono = { version = "0.4.37", features = ["serde"] }
openidconnect = { version = "4.0", default-features = false, features = ["accept-rfc3339-timestamps", "reqwest"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
subtle = "2.6"
mimalloc = { version = "*" }
[build-dependencies]
thunk-rs = { git = "https://github.com/easytier/thunk.git", default-features = false, features = [
"win7",
] }
[features]
default = []
embed = ["dep:axum-embed"]
# enable thunk-rs when compiling for x86_64 or i686 windows
[target.x86_64-pc-windows-msvc.build-dependencies]
thunk-rs = { git = "https://github.com/easytier/thunk.git", default-features = false, features = ["win7"] }
[target.i686-pc-windows-msvc.build-dependencies]
thunk-rs = { git = "https://github.com/easytier/thunk.git", default-features = false, features = ["win7"] }
+5 -5
View File
@@ -1,10 +1,10 @@
use std::env;
fn main() {
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
// enable thunk-rs when target os is windows and arch is x86_64 or i686
#[cfg(target_os = "windows")]
if !std::env::var("TARGET")
.unwrap_or_default()
.contains("aarch64")
{
if target_os == "windows" && (target_arch == "x86" || target_arch == "x86_64") {
thunk::thunk();
}
}
+2 -2
View File
@@ -20,7 +20,7 @@
"dependencies": {
"@primeuix/themes": "^1.2.3",
"@vueuse/core": "^11.1.0",
"axios": "^1.7.7",
"axios": "^1.13.5",
"chart.js": "^4.5.0",
"floating-vue": "^5.2",
"ip-num": "1.5.1",
@@ -41,7 +41,7 @@
"postcss-nested": "^7.0.2",
"tailwindcss": "=3.4.17",
"typescript": "~5.6.3",
"vite": "^5.4.10",
"vite": "^5.4.21",
"vite-plugin-dts": "^4.3.0",
"vue-tsc": "^2.1.10"
},
@@ -1,16 +1,18 @@
<script setup lang="ts">
import { AutoComplete, Button, Checkbox, Dialog, Divider, InputNumber, InputText, Panel, Password, SelectButton, ToggleButton } from 'primevue'
import InputGroup from 'primevue/inputgroup'
import InputGroupAddon from 'primevue/inputgroupaddon'
import { SelectButton, Checkbox, InputText, InputNumber, AutoComplete, Panel, Divider, ToggleButton, Button, Password, Dialog } from 'primevue'
import {
addRow,
DEFAULT_NETWORK_CONFIG,
NetworkConfig,
NetworkingMethod,
normalizeNetworkConfig,
removeRow
} from '../types/network'
import { defineProps, defineEmits, ref, onMounted, onUnmounted } from 'vue'
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import AclManager from './acl/AclManager.vue'
import UrlListInput from './UrlListInput.vue'
const props = defineProps<{
configInvalid?: boolean
@@ -26,63 +28,18 @@ const curNetwork = defineModel('curNetwork', {
const { t } = useI18n()
const networking_methods = ref([
{ value: NetworkingMethod.PublicServer, label: () => t('public_server') },
{ value: NetworkingMethod.Manual, label: () => t('manual') },
{ value: NetworkingMethod.Standalone, label: () => t('standalone') },
])
const protos: { [proto: string]: number } = { tcp: 11010, udp: 11010, wg: 11011, ws: 11011, wss: 11012 }
function searchUrlSuggestions(e: { query: string }): string[] {
const query = e.query
const ret = []
// if query match "^\w+:.*", then no proto prefix
if (query.match(/^\w+:.*/)) {
// if query is a valid url, then add to suggestions
try {
// eslint-disable-next-line no-new
new URL(query)
ret.push(query)
}
catch { }
}
else {
for (const proto in protos) {
let item = `${proto}://${query}`
// if query match ":\d+$", then no port suffix
if (!query.match(/:\d+$/)) {
item += `:${protos[proto]}`
}
ret.push(item)
}
}
return ret
}
const publicServerSuggestions = ref([''])
function searchPresetPublicServers(e: { query: string }) {
const presetPublicServers = [
'tcp://public.easytier.top:11010',
]
const query = e.query
// if query is sub string of presetPublicServers, add to suggestions
let ret = presetPublicServers.filter(item => item.includes(query))
// add additional suggestions
if (query.length > 0) {
ret = ret.concat(searchUrlSuggestions(e))
}
publicServerSuggestions.value = ret
}
const peerSuggestions = ref([''])
function searchPeerSuggestions(e: { query: string }) {
peerSuggestions.value = searchUrlSuggestions(e)
const protos: { [proto: string]: number } = {
tcp: 11010,
udp: 11010,
wg: 11011,
ws: 11011,
wss: 11012,
quic: 11012,
faketcp: 11013,
http: 80,
https: 443,
txt: 0,
srv: 0,
}
const inetSuggestions = ref([''])
@@ -99,34 +56,6 @@ function searchInetSuggestions(e: { query: string }) {
}
}
const listenerSuggestions = ref([''])
function searchListenerSuggestions(e: { query: string }) {
const ret = []
for (const proto in protos) {
let item = `${proto}://0.0.0.0:`
// if query is a number, use it as port
if (e.query.match(/^\d+$/)) {
item += e.query
}
else {
item += protos[proto]
}
if (item.includes(e.query)) {
ret.push(item)
}
}
if (ret.length === 0) {
ret.push(e.query)
}
listenerSuggestions.value = ret
}
const exitNodesSuggestions = ref([''])
function searchExitNodesSuggestions(e: { query: string }) {
@@ -152,21 +81,26 @@ const bool_flags: BoolFlag[] = [
{ field: 'latency_first', help: 'latency_first_help' },
{ field: 'use_smoltcp', help: 'use_smoltcp_help' },
{ field: 'disable_ipv6', help: 'disable_ipv6_help' },
{ field: 'ipv6_public_addr_auto', help: 'ipv6_public_addr_auto_help' },
{ field: 'enable_kcp_proxy', help: 'enable_kcp_proxy_help' },
{ field: 'disable_kcp_input', help: 'disable_kcp_input_help' },
{ 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: 'lazy_p2p', help: 'lazy_p2p_help' },
{ field: 'bind_device', help: 'bind_device_help' },
{ field: 'no_tun', help: 'no_tun_help' },
{ field: 'enable_exit_node', help: 'enable_exit_node_help' },
{ field: 'relay_all_peer_rpc', help: 'relay_all_peer_rpc_help' },
{ field: 'need_p2p', help: 'need_p2p_help' },
{ field: 'multi_thread', help: 'multi_thread_help' },
{ field: 'proxy_forward_by_system', help: 'proxy_forward_by_system_help' },
{ field: 'disable_encryption', help: 'disable_encryption_help' },
{ field: 'disable_tcp_hole_punching', help: 'disable_tcp_hole_punching_help' },
{ field: 'disable_udp_hole_punching', help: 'disable_udp_hole_punching_help' },
{ field: 'enable_udp_broadcast_relay', help: 'enable_udp_broadcast_relay_help' },
{ field: 'disable_upnp', help: 'disable_upnp_help' },
{ field: 'disable_sym_hole_punching', help: 'disable_sym_hole_punching_help' },
{ field: 'enable_magic_dns', help: 'enable_magic_dns_help' },
{ field: 'enable_private_mode', help: 'enable_private_mode_help' },
@@ -217,6 +151,16 @@ onMounted(() => {
});
}
});
function syncNormalizedNetwork(network: NetworkConfig | undefined): void {
if (!network) {
return
}
Object.assign(network, normalizeNetworkConfig(network))
}
watch(() => curNetwork.value, syncNormalizedNetwork, { immediate: true, deep: false })
</script>
<template>
@@ -263,17 +207,14 @@ onMounted(() => {
<div class="flex flex-row gap-x-9 flex-wrap">
<div class="flex flex-col gap-2 basis-5/12 grow">
<label for="nm">{{ t('networking_method') }}</label>
<SelectButton v-model="curNetwork.networking_method" :options="networking_methods"
:option-label="(v) => v.label()" option-value="value" />
<div class="items-center flex flex-row p-fluid gap-x-1">
<AutoComplete v-if="curNetwork.networking_method === NetworkingMethod.Manual" id="chips"
v-model="curNetwork.peer_urls" :placeholder="t('chips_placeholder', ['tcp://8.8.8.8:11010'])"
class="grow" multiple fluid :suggestions="peerSuggestions" @complete="searchPeerSuggestions" />
<AutoComplete v-if="curNetwork.networking_method === NetworkingMethod.PublicServer"
v-model="curNetwork.public_server_url" :suggestions="publicServerSuggestions" class="grow"
dropdown :complete-on-focus="false" @complete="searchPresetPublicServers" />
<div class="flex items-center">
<label for="initial_nodes">{{ t('initial_nodes') }}</label>
<span class="pi pi-question-circle ml-2 self-center" v-tooltip="t('initial_nodes_help')"></span>
</div>
<div class="items-center flex flex-col p-fluid gap-y-2">
<UrlListInput id="initial_nodes" v-model="curNetwork.peer_urls" :protos="protos"
defaultUrl="tcp://:11010" :add-label="t('add_initial_node')"
:placeholder="t('initial_node_placeholder')" />
</div>
</div>
</div>
@@ -345,10 +286,8 @@ onMounted(() => {
<div class="flex flex-row gap-x-9 flex-wrap">
<div class="flex flex-col gap-2 grow p-fluid">
<label for="listener_urls">{{ t('listener_urls') }}</label>
<AutoComplete id="listener_urls" v-model="curNetwork.listener_urls" :suggestions="listenerSuggestions"
class="w-full" dropdown :complete-on-focus="true"
:placeholder="t('chips_placeholder', ['tcp://1.1.1.1:11010'])" multiple
@complete="searchListenerSuggestions" />
<UrlListInput v-model="curNetwork.listener_urls" :protos="protos" :add-label="t('add_listener_url')"
placeholder="0.0.0.0" />
</div>
</div>
@@ -371,6 +310,19 @@ onMounted(() => {
</div>
</div>
<div class="flex flex-row gap-x-9 flex-wrap">
<div class="flex flex-col gap-2 basis-5/12 grow">
<div class="flex">
<label for="instance_recv_bps_limit">{{ t('instance_recv_bps_limit') }}</label>
<span class="pi pi-question-circle ml-2 self-center"
v-tooltip="t('instance_recv_bps_limit_help')"></span>
</div>
<InputNumber id="instance_recv_bps_limit" v-model="curNetwork.instance_recv_bps_limit"
aria-describedby="instance_recv_bps_limit-help" :format="false"
:placeholder="t('instance_recv_bps_limit_placeholder')" :min="1" fluid />
</div>
</div>
<div class="flex flex-row gap-x-9 flex-wrap">
<div class="flex flex-col gap-2 basis-5/12 grow">
<div class="flex">
@@ -443,9 +395,8 @@ onMounted(() => {
<label for="mapped_listeners">{{ t('mapped_listeners') }}</label>
<span class="pi pi-question-circle ml-2 self-center" v-tooltip="t('mapped_listeners_help')"></span>
</div>
<AutoComplete id="mapped_listeners" v-model="curNetwork.mapped_listeners"
:placeholder="t('chips_placeholder', ['tcp://123.123.123.123:11223'])" class="w-full" multiple fluid
:suggestions="peerSuggestions" @complete="searchPeerSuggestions" />
<UrlListInput v-model="curNetwork.mapped_listeners" :protos="protos"
:add-label="t('add_mapped_listener')" />
</div>
</div>
@@ -541,6 +492,18 @@ onMounted(() => {
</div>
</Panel>
<Divider />
<Panel :header="t('acl.title')" toggleable collapsed>
<div v-if="curNetwork.acl" class="flex flex-col gap-y-2">
<AclManager v-model="curNetwork.acl" />
</div>
<div v-else class="flex justify-center p-4">
<Button :label="t('acl.enabled')"
@click="curNetwork.acl = { acl_v1: { chains: [], group: { declares: [], members: [] } } }" />
</div>
</Panel>
<div class="flex pt-6 justify-center">
<Button :label="t('run_network')" icon="pi pi-arrow-right" icon-pos="right" :disabled="configInvalid"
@click="$emit('runNetwork', curNetwork)" />
@@ -206,27 +206,39 @@ const confirmDeleteNetwork = (event: any) => {
});
};
const saveAndRunNewNetwork = async () => {
if (!currentNetworkConfig.value) {
const saveAndRunNewNetwork = async (config?: NetworkTypes.NetworkConfig) => {
const cfg = config ?? currentNetworkConfig.value;
if (!cfg) {
return;
}
const targetInstanceId = instanceId.value ?? cfg.instance_id;
if (targetInstanceId && cfg.instance_id !== targetInstanceId) {
cfg.instance_id = targetInstanceId;
}
try {
await props.api.delete_network(instanceId.value!);
let ret = await props.api.run_network(currentNetworkConfig.value, currentNetworkControl.remoteSave.value);
console.debug("saveAndRunNewNetwork", ret);
if (networkIsDisabled.value) {
await props.api.save_config(cfg);
await props.api.update_network_instance_state(cfg.instance_id, false);
} else {
await props.api.run_network(cfg, currentNetworkControl.remoteSave.value);
}
delete networkMetaCache.value[currentNetworkConfig.value.instance_id];
await loadNetworkMetas([currentNetworkConfig.value.instance_id]);
delete networkMetaCache.value[cfg.instance_id];
await loadNetworkMetas([cfg.instance_id]);
selectedInstanceId.value = { uuid: currentNetworkConfig.value.instance_id };
selectedInstanceId.value = { uuid: cfg.instance_id };
await loadNetworkInstanceIds();
await loadCurrentNetworkInfo();
} catch (e: any) {
console.error(e);
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to create network, error: ' + JSON.stringify(e.response.data), life: 2000 });
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to run network, error: ' + JSON.stringify(e.response?.data ?? e), life: 2000 });
return;
}
emits('update');
// showCreateNetworkDialog.value = false;
isEditingNetwork.value = false; // Exit creation mode after successful network creation
isEditingNetwork.value = false;
}
const saveNetworkConfig = async () => {
@@ -388,18 +400,18 @@ const updateScreenWidth = () => {
const menuRef = ref();
const actionMenu: Ref<MenuItem[]> = ref([
{
label: t('web.device_management.edit_network'),
label: () => t('web.device_management.edit_network'),
icon: 'pi pi-pencil',
visible: () => !(networkIsDisabled.value ?? true) && currentNetworkControl.editable.value,
command: () => editNetwork()
},
{
label: t('web.device_management.export_config'),
label: () => t('web.device_management.export_config'),
icon: 'pi pi-download',
command: () => exportConfig()
},
{
label: t('web.device_management.delete_network'),
label: () => t('web.device_management.delete_network'),
icon: 'pi pi-trash',
class: 'p-error',
visible: () => currentNetworkControl.deletable.value,
@@ -539,13 +551,15 @@ onUnmounted(() => {
:label="t('web.device_management.edit_as_file')" iconPos="left" severity="secondary" />
<Button @click="importConfig" icon="pi pi-upload" :label="t('web.device_management.import_config')"
iconPos="left" severity="help" />
<Button v-if="networkIsDisabled" @click="saveNetworkConfig" icon="pi pi-save"
:label="t('web.device_management.save_config')" iconPos="left" severity="success" />
<Button v-if="networkIsDisabled" @click="saveNetworkConfig" :disabled="!currentNetworkConfig"
icon="pi pi-save" :label="t('web.device_management.save_config')" iconPos="left"
severity="success" />
</div>
<Divider />
<Config :cur-network="currentNetworkConfig" @run-network="saveAndRunNewNetwork"></Config>
<Config :cur-network="currentNetworkConfig" :config-invalid="!currentNetworkConfig"
@run-network="saveAndRunNewNetwork"></Config>
</div>
<!-- Network Status (for running networks) -->
@@ -183,6 +183,12 @@ const myNodeInfoChips = computed(() => {
if (!my_node_info)
return chips
// peer id
chips.push({
label: `Peer ID: ${my_node_info.peer_id}`,
icon: '',
} as Chip)
// TUN Device Name
const dev_name = props.curNetworkInst.detail?.dev_name
if (dev_name) {
@@ -0,0 +1,242 @@
<script setup lang="ts">
import { AutoComplete, Button, Dialog, InputNumber, InputText } from 'primevue'
import InputGroup from 'primevue/inputgroup'
import InputGroupAddon from 'primevue/inputgroupaddon'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps<{
placeholder?: string
protos: { [proto: string]: number }
}>()
const { t } = useI18n()
const url = defineModel<string>({ required: true })
const editing = ref(false)
const hostFocused = ref(false)
const parseUrl = (val: string | null | undefined): { proto: string; host: string; port: number | null } => {
const getValidPort = (portStr: string, proto: string) => {
const p = parseInt(portStr)
return isNaN(p) ? (props.protos[proto] ?? 11010) : p
}
const parseByPattern = (input: string) => {
const trimmed = input.trim()
if (!trimmed) {
return null
}
const match = trimmed.match(/^(\w+):\/\/(.*)$/)
const proto = match ? match[1] : 'tcp'
const rest = match ? match[2] : trimmed
const authority = rest.split(/[/?#]/)[0]
if (!authority) {
return null
}
const hostAndMaybePort = authority.includes('@') ? authority.slice(authority.lastIndexOf('@') + 1) : authority
if (hostAndMaybePort.startsWith('[')) {
const ipv6End = hostAndMaybePort.indexOf(']')
if (ipv6End > 0) {
const host = hostAndMaybePort.slice(0, ipv6End + 1)
const remain = hostAndMaybePort.slice(ipv6End + 1)
// null = no explicit port in URL; do not fabricate a default
const port: number | null = remain.startsWith(':') ? getValidPort(remain.slice(1), proto) : null
return { proto, host, port }
}
}
const portMatch = hostAndMaybePort.match(/^(.*):(\d+)$/)
const host = portMatch ? portMatch[1] : hostAndMaybePort
// null = no explicit port in URL; buildUrlValue will omit the port entirely,
// preserving the protocol's implied standard port (e.g. 443 for wss://).
const port: number | null = portMatch ? parseInt(portMatch[2]) : null
return { proto, host, port }
}
if (!val) {
return { proto: 'tcp', host: '', port: props.protos['tcp'] ?? 11010 }
}
const parsedByPattern = parseByPattern(val)
if (parsedByPattern) {
return parsedByPattern
}
return { proto: 'tcp', host: '', port: null }
}
const internalValue = ref(parseUrl(url.value))
const defaultHost = '0.0.0.0'
const buildUrlValue = (value: { proto: string, host: string, port: number | null }, forceDefaultHost = false) => {
const proto = value.proto || 'tcp'
const rawHost = (value.host ?? '').trim()
const host = rawHost || (forceDefaultHost ? defaultHost : '')
if (!host) {
return null
}
// Omit port when the protocol uses no port (protos value = 0), or when the
// original URL had no explicit port (port === null) avoids overwriting an
// implicit standard port (e.g. 443 for wss) with an EasyTier default (11012).
if (props.protos[proto] === 0 || value.port === null) {
return `${proto}://${host}`
}
return `${proto}://${host}:${value.port}`
}
const syncUrlFromInternal = (forceDefaultHost = false) => {
const nextUrl = buildUrlValue(internalValue.value, forceDefaultHost)
if (!nextUrl || nextUrl === url.value) {
return
}
url.value = nextUrl
}
const onHostBlur = () => {
hostFocused.value = false
syncUrlFromInternal(true)
}
const onHostFocus = () => {
hostFocused.value = true
}
const onDialogConfirm = () => {
syncUrlFromInternal(true)
editing.value = false
}
const isNoPortProto = computed(() => {
return props.protos[internalValue.value.proto] === 0
})
// Sync from external
watch(() => url.value, (newVal) => {
if (hostFocused.value) {
return
}
const parsed = parseUrl(newVal)
const internalHost = internalValue.value.host ?? ''
const sameHost = parsed.host === internalHost || (!internalHost.trim() && parsed.host === defaultHost)
if (parsed.proto !== internalValue.value.proto ||
!sameHost ||
parsed.port !== internalValue.value.port) {
internalValue.value = parsed
}
})
// Sync to external
watch(internalValue, () => {
syncUrlFromInternal(false)
}, { deep: true })
const protoOptions = computed(() => Object.keys(props.protos))
const filteredProtos = ref<string[]>([])
const searchProtos = (event: { query: string }) => {
if (!event.query.trim().length) {
filteredProtos.value = [...protoOptions.value]
} else {
filteredProtos.value = protoOptions.value.filter((proto) => {
return proto.toLowerCase().startsWith(event.query.toLowerCase())
})
}
}
const onProtoChange = (newProto: string) => {
const oldProto = internalValue.value.proto
const oldDefault = props.protos[oldProto]
const newDefault = props.protos[newProto]
if (oldDefault !== undefined && internalValue.value.port === oldDefault && newDefault !== undefined) {
internalValue.value.port = newDefault
}
internalValue.value.proto = newProto
}
</script>
<template>
<div class="url-input-container w-full min-w-0 overflow-hidden">
<InputGroup class="url-input-full w-full min-w-0">
<AutoComplete :model-value="internalValue.proto" :suggestions="filteredProtos" dropdown
class="max-w-32 proto-autocomplete-in-group" @complete="searchProtos"
@update:model-value="onProtoChange" />
<InputText v-model="internalValue.host" :placeholder="placeholder || '0.0.0.0'" class="grow min-w-0"
@focus="onHostFocus" @blur="onHostBlur" />
<template v-if="!isNoPortProto">
<InputGroupAddon>
<span style="font-weight: bold">:</span>
</InputGroupAddon>
<InputNumber v-model="internalValue.port" :format="false" :min="1" :max="65535" class="max-w-24"
:placeholder="String(protos[internalValue.proto] ?? 11010)" fluid />
</template>
<!-- Rendered in both responsive branches; keep action slot content free of side effects and duplicate IDs. -->
<slot name="actions"></slot>
</InputGroup>
<div
class="url-input-compact flex justify-between items-center p-2 border rounded w-full min-w-0 overflow-hidden">
<span class="truncate mr-2 min-w-0 flex-1 overflow-hidden">{{ url }}</span>
<div class="flex items-center shrink-0">
<Button icon="pi pi-pencil" class="p-button-sm p-button-text" :aria-label="t('web.common.edit')"
@click="editing = true" />
<slot name="actions"></slot>
</div>
</div>
<Dialog v-model:visible="editing" modal :header="placeholder" :style="{ width: '90vw', maxWidth: '500px' }">
<div class="flex flex-col gap-4 py-4">
<div class="flex flex-col gap-2">
<label>{{ t('tunnel_proto') }}</label>
<AutoComplete :model-value="internalValue.proto" :suggestions="filteredProtos" dropdown fluid
@complete="searchProtos" @update:model-value="onProtoChange" />
</div>
<div class="flex flex-col gap-2">
<label>{{ t('web.common.address') || 'Address' }}</label>
<InputText v-model="internalValue.host" :placeholder="placeholder || '0.0.0.0'" class="w-full"
@focus="onHostFocus" @blur="onHostBlur" />
</div>
<div v-if="!isNoPortProto" class="flex flex-col gap-2">
<label>{{ t('port') }}</label>
<InputNumber v-model="internalValue.port" :format="false" :min="1" :max="65535" class="w-full"
:placeholder="String(protos[internalValue.proto] ?? 11010)" />
</div>
</div>
<template #footer>
<Button :label="t('web.common.confirm') || 'Done'" icon="pi pi-check" @click="onDialogConfirm"
autofocus />
</template>
</Dialog>
</div>
</template>
<style scoped>
.url-input-container {
container-type: inline-size;
}
.url-input-full {
display: none;
}
.url-input-compact {
display: flex;
}
@container (min-width: 400px) {
.url-input-full {
display: flex;
}
.url-input-compact {
display: none;
}
}
.proto-autocomplete-in-group,
.proto-autocomplete-in-group :deep(.p-autocomplete-input),
.proto-autocomplete-in-group :deep(.p-autocomplete-dropdown) {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
.proto-autocomplete-in-group :deep(.p-autocomplete-dropdown) {
border-right: 0 !important;
}
</style>
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { Button } from 'primevue'
import UrlInput from './UrlInput.vue'
const props = defineProps<{
protos: { [proto: string]: number }
addLabel: string
placeholder?: string
defaultUrl?: string
}>()
const list = defineModel<string[]>({ required: true })
const addUrl = () => {
list.value.push(props.defaultUrl || 'tcp://0.0.0.0:11010')
}
const removeUrl = (index: number) => {
list.value.splice(index, 1)
}
</script>
<template>
<div class="flex flex-col gap-y-2 w-full">
<div v-for="(_, index) in list" :key="index" class="flex gap-2 items-center w-full">
<UrlInput v-model="list[index]" :protos="protos" :placeholder="placeholder">
<template #actions>
<Button icon="pi pi-trash" severity="danger" text rounded @click="removeUrl(index)" />
</template>
</UrlInput>
</div>
<div class="flex justify-center items-center w-full h-10 border-2 border-dashed border-surface-300 dark:border-surface-600 rounded-lg cursor-pointer hover:border-primary hover:bg-surface-50 dark:hover:bg-surface-800 transition-colors duration-200 gap-2 text-surface-500 dark:text-surface-400"
@click="addUrl">
<i class="pi pi-plus text-sm"></i>
<span class="text-sm font-medium">{{ addLabel }}</span>
</div>
</div>
</template>
@@ -0,0 +1,218 @@
<script setup lang="ts">
import { Button, Column, DataTable, Divider, InputText, Select, SelectButton, ToggleButton } from 'primevue'
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { AclAction, AclChain, AclChainType, AclProtocol, AclRule } from '../../types/network'
import AclRuleDialog from './AclRuleDialog.vue'
const props = defineProps<{
groupNames?: string[]
}>()
const chain = defineModel<AclChain>({ required: true })
const { t } = useI18n()
watch(() => chain.value.rules, (newRules) => {
if (!newRules) return
const isSorted = newRules.every((rule, i) => i === 0 || (rule.priority || 0) <= (newRules[i - 1].priority || 0))
if (!isSorted) {
chain.value.rules.sort((a, b) => (b.priority || 0) - (a.priority || 0))
}
}, { deep: true, immediate: true })
const actionOptions = [
{ label: () => t('acl.allow'), value: AclAction.Allow },
{ label: () => t('acl.drop'), value: AclAction.Drop },
]
const chainTypeOptions = [
{ label: () => t('acl.inbound'), value: AclChainType.Inbound },
{ label: () => t('acl.outbound'), value: AclChainType.Outbound },
{ label: () => t('acl.forward'), value: AclChainType.Forward },
]
const editingRule = ref<AclRule | null>(null)
const editingRuleIndex = ref(-1)
const showRuleDialog = ref(false)
function getProtocolLabel(proto: AclProtocol) {
switch (proto) {
case AclProtocol.Any: return t('acl.any')
case AclProtocol.TCP: return 'TCP'
case AclProtocol.UDP: return 'UDP'
case AclProtocol.ICMP: return 'ICMP'
case AclProtocol.ICMPv6: return 'ICMPv6'
default: return t('event.Unknown')
}
}
function getActionLabel(action: AclAction) {
switch (action) {
case AclAction.Allow: return t('acl.allow')
case AclAction.Drop: return t('acl.drop')
default: return t('event.Unknown')
}
}
function addRule() {
editingRuleIndex.value = -1
editingRule.value = {
name: '',
description: '',
priority: chain.value.rules.length,
enabled: true,
protocol: AclProtocol.Any,
ports: [],
source_ips: [],
destination_ips: [],
source_ports: [],
action: AclAction.Allow,
rate_limit: 0,
burst_limit: 0,
stateful: false,
source_groups: [],
destination_groups: [],
}
showRuleDialog.value = true
}
function editRule(index: number) {
editingRuleIndex.value = index
editingRule.value = JSON.parse(JSON.stringify(chain.value.rules[index]))
showRuleDialog.value = true
}
function deleteRule(index: number) {
chain.value.rules.splice(index, 1)
}
function saveRule(rule: AclRule) {
if (editingRuleIndex.value === -1) {
chain.value.rules.push(rule)
} else {
chain.value.rules[editingRuleIndex.value] = rule
}
chain.value.rules.sort((a, b) => (b.priority || 0) - (a.priority || 0))
}
function onRowReorder(event: any) {
chain.value.rules = event.value
// Update priorities based on new order (higher priority at top)
chain.value.rules.forEach((rule, index) => {
rule.priority = chain.value.rules.length - index - 1
})
}
</script>
<template>
<div class="flex flex-col gap-6">
<!-- Chain Metadata Section -->
<div
class="grid grid-cols-1 md:grid-cols-2 gap-4 p-4 bg-gray-50 rounded-lg border border-gray-200 dark:bg-gray-900 dark:border-gray-700">
<div class="flex flex-col gap-2">
<label class="font-bold text-sm">{{ t('acl.chain.name') }}</label>
<InputText v-model="chain.name" size="small" />
</div>
<div class="flex flex-col gap-2">
<label class="font-bold text-sm">{{ t('acl.rule.description') }}</label>
<InputText v-model="chain.description" size="small" />
</div>
<div class="flex items-center gap-6 col-span-full border-t pt-2 mt-2 dark:border-gray-700">
<div class="flex items-center gap-2">
<label class="font-bold text-sm">{{ t('acl.rule.enabled') }}</label>
<ToggleButton v-model="chain.enabled" on-icon="pi pi-check" off-icon="pi pi-times"
:on-label="t('web.common.enable')" :off-label="t('web.common.disable')" class="w-24" />
</div>
<div class="flex items-center gap-2">
<label class="font-bold text-sm">{{ t('acl.chain.type') }}</label>
<Select v-model="chain.chain_type" :options="chainTypeOptions" :option-label="opt => opt.label()"
option-value="value" size="small" class="w-40" />
</div>
<div class="flex items-center gap-2 ml-auto">
<label class="font-bold text-sm">{{ t('acl.default_action') }}</label>
<SelectButton v-model="chain.default_action" :options="actionOptions" :option-label="opt => opt.label()"
option-value="value" :allow-empty="false" />
</div>
</div>
</div>
<div class="flex flex-row items-center gap-4 justify-between">
<h4 class="text-md font-bold">{{ t('acl.rules') }}</h4>
<Button icon="pi pi-plus" :label="t('acl.add_rule')" severity="success" size="small" @click="addRule" />
</div>
<DataTable :value="chain.rules" @row-reorder="onRowReorder" responsiveLayout="scroll">
<Column rowReorder headerStyle="width: 3rem" />
<Column field="enabled" :header="t('acl.rule.enabled')">
<template #body="{ data }">
<i class="pi" :class="data.enabled ? 'pi-check-circle text-green-500' : 'pi-times-circle text-red-500'"></i>
</template>
</Column>
<Column field="name" :header="t('acl.rule.name')" />
<Column :header="t('acl.match')">
<template #body="{ data }">
<div class="flex flex-col gap-2 py-1">
<div class="flex items-center gap-2">
<span
class="px-2 py-0.5 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 rounded-md text-[10px] font-bold uppercase tracking-wider">
{{ getProtocolLabel(data.protocol) }}
</span>
</div>
<div class="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-3">
<div class="flex items-center gap-1.5 min-w-0">
<span class="text-[10px] font-bold text-gray-400 uppercase w-7">Src</span>
<div class="flex flex-wrap gap-1 items-center overflow-hidden">
<span v-for="ip in data.source_ips" :key="ip"
class="font-mono text-xs bg-surface-100 dark:bg-surface-800 px-1.5 py-0.5 rounded">{{ ip }}</span>
<span v-for="grp in data.source_groups" :key="grp"
class="text-xs font-bold text-purple-600 dark:text-purple-400">@{{ grp }}</span>
<span v-if="data.source_ports.length" class="text-xs text-blue-600 dark:text-blue-400 font-mono">:{{
data.source_ports.join(',') }}</span>
<span v-if="!data.source_ips.length && !data.source_groups.length" class="text-gray-400">*</span>
</div>
</div>
<i class="pi pi-arrow-right hidden sm:block text-gray-300 text-xs"></i>
<Divider layout="horizontal" class="sm:hidden my-1" />
<div class="flex items-center gap-1.5 min-w-0">
<span class="text-[10px] font-bold text-gray-400 uppercase w-7">Dst</span>
<div class="flex flex-wrap gap-1 items-center overflow-hidden">
<span v-for="ip in data.destination_ips" :key="ip"
class="font-mono text-xs bg-surface-100 dark:bg-surface-800 px-1.5 py-0.5 rounded">{{ ip }}</span>
<span v-for="grp in data.destination_groups" :key="grp"
class="text-xs font-bold text-purple-600 dark:text-purple-400">@{{ grp }}</span>
<span v-if="data.ports.length" class="text-xs text-blue-600 dark:text-blue-400 font-mono">:{{
data.ports.join(',') }}</span>
<span v-if="!data.destination_ips.length && !data.destination_groups.length"
class="text-gray-400">*</span>
</div>
</div>
</div>
</div>
</template>
</Column>
<Column field="action" :header="t('acl.rule.action')">
<template #body="{ data }">
<span :class="data.action === AclAction.Allow ? 'text-green-600' : 'text-red-600 font-bold'">
{{ getActionLabel(data.action) }}
</span>
</template>
</Column>
<Column :header="t('web.common.edit')">
<template #body="{ index }">
<div class="flex gap-2">
<Button icon="pi pi-pencil" text rounded @click="editRule(index)" />
<Button icon="pi pi-trash" severity="danger" text rounded @click="deleteRule(index)" />
</div>
</template>
</Column>
</DataTable>
<AclRuleDialog v-if="showRuleDialog && editingRule" v-model:visible="showRuleDialog" v-model:rule="editingRule"
:group-names="props.groupNames" @save="saveRule" />
</div>
</template>
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { Button, Column, DataTable, Dialog, InputText, MultiSelect, Password } from 'primevue';
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { GroupIdentity, GroupInfo } from '../../types/network';
const props = defineProps<{
groupNames?: string[]
}>()
const group = defineModel<GroupInfo>({ required: true })
const emit = defineEmits(['rename-group'])
const { t } = useI18n()
const editingGroup = ref<GroupIdentity | null>(null)
const editingGroupIndex = ref(-1)
const showGroupDialog = ref(false)
const oldGroupName = ref('')
function addGroup() {
editingGroupIndex.value = -1
editingGroup.value = {
group_name: '',
group_secret: '',
}
oldGroupName.value = ''
showGroupDialog.value = true
}
function editGroup(index: number) {
editingGroupIndex.value = index
editingGroup.value = JSON.parse(JSON.stringify(group.value.declares[index]))
oldGroupName.value = editingGroup.value?.group_name || ''
showGroupDialog.value = true
}
function deleteGroup(index: number) {
group.value.declares.splice(index, 1)
}
function saveGroup() {
if (!editingGroup.value) return
const newName = editingGroup.value.group_name
if (editingGroupIndex.value === -1) {
group.value.declares.push(editingGroup.value)
} else {
if (oldGroupName.value && oldGroupName.value !== newName) {
// Sync in members
group.value.members = group.value.members.map(m => m === oldGroupName.value ? newName : m)
// Notify parent to sync in rules
emit('rename-group', { oldName: oldGroupName.value, newName })
}
group.value.declares[editingGroupIndex.value] = editingGroup.value
}
showGroupDialog.value = false
}
</script>
<template>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2">
<div class="flex justify-between items-center">
<div class="flex flex-col">
<label class="font-bold text-lg">{{ t('acl.group.declares') }}</label>
<small class="text-gray-500">{{ t('acl.group.help') }}</small>
</div>
<Button icon="pi pi-plus" :label="t('web.common.add')" severity="success" @click="addGroup" />
</div>
<DataTable :value="group.declares" responsiveLayout="scroll">
<Column field="group_name" :header="t('acl.group.name')" />
<Column field="group_secret" :header="t('acl.group.secret')">
<template #body="{ data }">
<Password v-model="data.group_secret" :feedback="false" toggleMask readonly plain class="w-full" />
</template>
</Column>
<Column :header="t('web.common.edit')" headerStyle="width: 8rem">
<template #body="{ index }">
<div class="flex gap-2">
<Button icon="pi pi-pencil" text rounded @click="editGroup(index)" />
<Button icon="pi pi-trash" severity="danger" text rounded @click="deleteGroup(index)" />
</div>
</template>
</Column>
</DataTable>
</div>
<div class="flex flex-col gap-2">
<label class="font-bold text-lg">{{ t('acl.group.members') }}</label>
<MultiSelect v-model="group.members" :options="props.groupNames" multiple fluid filter
:placeholder="t('acl.group.members')" />
</div>
<!-- Group Identity Dialog -->
<Dialog v-model:visible="showGroupDialog" modal :header="t('acl.groups')" :style="{ width: '400px' }">
<div v-if="editingGroup" class="flex flex-col gap-4 pt-2">
<div class="flex flex-col gap-2">
<label class="font-bold">{{ t('acl.group.name') }}</label>
<InputText v-model="editingGroup.group_name" fluid />
</div>
<div class="flex flex-col gap-2">
<label class="font-bold">{{ t('acl.group.secret') }}</label>
<Password v-model="editingGroup.group_secret" :feedback="false" toggleMask fluid />
</div>
</div>
<template #footer>
<Button :label="t('web.common.cancel')" icon="pi pi-times" @click="showGroupDialog = false" text />
<Button :label="t('web.common.save')" icon="pi pi-save" @click="saveGroup" />
</template>
</Dialog>
</div>
</template>
@@ -0,0 +1,150 @@
<script setup lang="ts">
import { Button, Menu, Tab, TabList, TabPanel, TabPanels, Tabs } from 'primevue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Acl, AclAction, AclChainType } from '../../types/network'
import AclChainEditor from './AclChainEditor.vue'
import AclGroupEditor from './AclGroupEditor.vue'
const acl = defineModel<Acl>({ required: true })
const { t } = useI18n()
const activeTab = ref(0)
const menu = ref()
const addMenuModel = ref([
{ label: () => t('acl.inbound'), command: () => addChain(AclChainType.Inbound) },
{ label: () => t('acl.outbound'), command: () => addChain(AclChainType.Outbound) },
{ label: () => t('acl.forward'), command: () => addChain(AclChainType.Forward) },
])
function addChain(type: AclChainType) {
if (!acl.value.acl_v1) {
acl.value.acl_v1 = { chains: [], group: { declares: [], members: [] } }
}
let defaultName = ''
switch (type) {
case AclChainType.Inbound: defaultName = 'Inbound'; break;
case AclChainType.Outbound: defaultName = 'Outbound'; break;
case AclChainType.Forward: defaultName = 'Forward'; break;
}
acl.value.acl_v1.chains.push({
name: defaultName,
chain_type: type,
description: '',
enabled: true,
rules: [],
default_action: AclAction.Allow
})
activeTab.value = acl.value.acl_v1.chains.length - 1
}
function removeChain(index: number) {
if (confirm(t('acl.delete_chain_confirm'))) {
acl.value.acl_v1?.chains.splice(index, 1)
if (activeTab.value >= (acl.value.acl_v1?.chains.length || 0)) {
activeTab.value = Math.max(0, (acl.value.acl_v1?.chains.length || 0))
}
}
}
function handleRenameGroup({ oldName, newName }: { oldName: string, newName: string }) {
if (!acl.value.acl_v1) return
acl.value.acl_v1.chains.forEach(chain => {
chain.rules.forEach(rule => {
rule.source_groups = rule.source_groups.map(g => g === oldName ? newName : g)
rule.destination_groups = rule.destination_groups.map(g => g === oldName ? newName : g)
})
})
}
const groupNames = computed(() => {
return acl.value.acl_v1?.group?.declares.map(g => g.group_name) || []
})
const tabs = computed(() => {
const chains = acl.value.acl_v1?.chains || []
const result: { type: string, label: string, index: number }[] = []
if (chains.length === 0) {
result.push({ type: 'empty', label: t('acl.chains'), index: 0 })
}
else {
chains.forEach((c, index) => {
result.push({
type: 'chain',
label: c.name || `Chain ${index}`,
index
})
})
}
result.push({ type: 'groups', label: t('acl.groups'), index: result.length })
return result
})
</script>
<template>
<div class="flex flex-col gap-4">
<Tabs v-model:value="activeTab">
<div class="flex items-center border-b border-surface-200 dark:border-surface-700">
<TabList class="flex-grow min-w-0 overflow-x-auto" style="border-bottom: none;">
<Tab v-for="tab in tabs" :key="tab.type + tab.index" :value="tab.index">
<div class="flex items-center gap-2 whitespace-nowrap">
{{ tab.label }}
<Button v-if="tab.type === 'chain'" icon="pi pi-times" severity="danger" text rounded size="small"
class="w-6 h-6 p-0" @click.stop="removeChain(tab.index)" />
</div>
</Tab>
</TabList>
<div
class="flex-shrink-0 flex items-center px-2 bg-white dark:bg-gray-900 border-l border-surface-100 dark:border-surface-800">
<Button icon="pi pi-plus" text rounded size="small" class="w-8 h-8 p-0"
@click="(event) => menu.toggle(event)" />
<Menu ref="menu" :model="addMenuModel" :popup="true" />
</div>
</div>
<TabPanels>
<TabPanel v-for="tab in tabs" :key="'panel' + tab.type + tab.index" :value="tab.index">
<!-- Empty State within TabPanel -->
<div v-if="tab.type === 'empty'"
class="py-8 flex flex-col items-center justify-center border-2 border-dashed border-surface-200 rounded-lg bg-surface-50 dark:bg-surface-900 dark:border-surface-700">
<i class="pi pi-shield text-5xl mb-4 text-primary" />
<div class="text-xl font-bold mb-2">{{ t('acl.chains') }}</div>
<p class="text-surface-500 mb-8 text-center max-w-sm px-4">{{ t('acl.help') }}</p>
<div class="flex flex-wrap gap-3 justify-center">
<Button :label="t('acl.inbound')" icon="pi pi-arrow-down-left" @click="addChain(AclChainType.Inbound)" />
<Button :label="t('acl.outbound')" icon="pi pi-arrow-up-right" @click="addChain(AclChainType.Outbound)" />
<Button :label="t('acl.forward')" icon="pi pi-directions" @click="addChain(AclChainType.Forward)" />
</div>
</div>
<!-- Rule Chains -->
<div v-if="tab.type === 'chain' && acl.acl_v1 && acl.acl_v1.chains[tab.index]" class="py-4">
<AclChainEditor v-model="acl.acl_v1.chains[tab.index]" :group-names="groupNames" />
</div>
<!-- Group Management -->
<div v-if="tab.type === 'groups'" class="py-4">
<template v-if="acl.acl_v1">
<AclGroupEditor v-if="acl.acl_v1.group" v-model="acl.acl_v1.group" :group-names="groupNames"
@rename-group="handleRenameGroup" />
<div v-else class="flex justify-center p-4">
<Button :label="t('web.common.add') + ' ' + t('acl.groups')"
@click="acl.acl_v1.group = { declares: [], members: [] }" />
</div>
</template>
<div v-else class="flex justify-center p-4">
<Button :label="t('acl.enabled')"
@click="acl.acl_v1 = { chains: [], group: { declares: [], members: [] } }" />
</div>
</div>
</TabPanel>
</TabPanels>
</Tabs>
</div>
</template>
@@ -0,0 +1,150 @@
<script setup lang="ts">
import { AutoComplete, Button, Checkbox, Dialog, InputNumber, InputText, MultiSelect, Panel, SelectButton, ToggleButton } from 'primevue';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { AclAction, AclProtocol, AclRule } from '../../types/network';
const props = defineProps<{
visible: boolean
groupNames?: string[]
}>()
const emit = defineEmits(['update:visible', 'save'])
const rule = defineModel<AclRule>('rule', { required: true })
const { t } = useI18n()
const protocolOptions = [
{ label: () => t('acl.any'), value: AclProtocol.Any },
{ label: 'TCP', value: AclProtocol.TCP },
{ label: 'UDP', value: AclProtocol.UDP },
{ label: 'ICMP', value: AclProtocol.ICMP },
{ label: 'ICMPv6', value: AclProtocol.ICMPv6 },
]
const actionOptions = [
{ label: () => t('acl.allow'), value: AclAction.Allow },
{ label: () => t('acl.drop'), value: AclAction.Drop },
]
const showPorts = computed(() => {
return rule.value.protocol === AclProtocol.TCP || rule.value.protocol === AclProtocol.UDP || rule.value.protocol === AclProtocol.Any
})
function close() {
emit('update:visible', false)
}
function save() {
emit('save', rule.value)
close()
}
// Suggestions for IP/Port AutoComplete
const genericSuggestions = ref<string[]>([])
</script>
<template>
<Dialog :visible="visible" @update:visible="emit('update:visible', $event)" modal :header="t('acl.edit_rule')"
:style="{ width: '90vw', maxWidth: '600px' }">
<div class="flex flex-col gap-4">
<div class="flex flex-row gap-4 items-center">
<div class="flex flex-col gap-2 grow">
<label class="font-bold">{{ t('acl.rule.name') }}</label>
<InputText v-model="rule.name" fluid />
</div>
<div class="flex flex-col gap-2">
<label class="font-bold">{{ t('acl.rule.enabled') }}</label>
<ToggleButton v-model="rule.enabled" on-icon="pi pi-check" off-icon="pi pi-times"
:on-label="t('web.common.enable')" :off-label="t('web.common.disable')" class="w-24" />
</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-bold">{{ t('acl.rule.description') }}</label>
<InputText v-model="rule.description" fluid />
</div>
<div class="flex flex-row gap-4 flex-wrap">
<div class="flex flex-col gap-2 grow">
<label class="font-bold">{{ t('acl.rule.action') }}</label>
<SelectButton v-model="rule.action" :options="actionOptions" :option-label="opt => opt.label()"
option-value="value" :allow-empty="false" />
</div>
<div class="flex flex-col gap-2 grow">
<label class="font-bold">{{ t('acl.rule.protocol') }}</label>
<SelectButton v-model="rule.protocol" :options="protocolOptions"
:option-label="opt => typeof opt.label === 'function' ? opt.label() : opt.label" option-value="value"
:allow-empty="false" />
</div>
</div>
<Panel :header="t('acl.rules')" toggleable>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<label class="font-bold">{{ t('acl.rule.src_ips') }}</label>
<AutoComplete v-model="rule.source_ips" multiple fluid :suggestions="genericSuggestions"
@complete="genericSuggestions = [$event.query]"
:placeholder="t('chips_placeholder', ['10.126.126.0/24'])" />
</div>
<div class="flex flex-col gap-2">
<label class="font-bold">{{ t('acl.rule.dst_ips') }}</label>
<AutoComplete v-model="rule.destination_ips" multiple fluid :suggestions="genericSuggestions"
@complete="genericSuggestions = [$event.query]"
:placeholder="t('chips_placeholder', ['10.126.126.2/32'])" />
</div>
<div v-if="showPorts" class="flex flex-row gap-4 flex-wrap">
<div class="flex flex-col gap-2 grow">
<label class="font-bold">{{ t('acl.rule.src_ports') }}</label>
<AutoComplete v-model="rule.source_ports" multiple fluid :suggestions="genericSuggestions"
@complete="genericSuggestions = [$event.query]" placeholder="e.g. 80, 1000-2000" />
</div>
<div class="flex flex-col gap-2 grow">
<label class="font-bold">{{ t('acl.rule.dst_ports') }}</label>
<AutoComplete v-model="rule.ports" multiple fluid :suggestions="genericSuggestions"
@complete="genericSuggestions = [$event.query]" placeholder="e.g. 80, 1000-2000" />
</div>
</div>
</div>
</Panel>
<Panel :header="t('advanced_settings')" toggleable collapsed>
<div class="flex flex-col gap-4">
<div class="flex items-center gap-2">
<Checkbox v-model="rule.stateful" :binary="true" inputId="rule-stateful" />
<label for="rule-stateful" class="font-bold">{{ t('acl.rule.stateful') }}</label>
</div>
<div class="flex flex-row gap-4 flex-wrap">
<div class="flex flex-col gap-2 grow">
<label class="font-bold">{{ t('acl.rule.rate_limit') }}</label>
<InputNumber v-model="rule.rate_limit" :min="0" placeholder="0 = no limit" fluid />
</div>
<div class="flex flex-col gap-2 grow">
<label class="font-bold">{{ t('acl.rule.burst_limit') }}</label>
<InputNumber v-model="rule.burst_limit" :min="0" placeholder="0 = no limit" fluid />
</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-bold">{{ t('acl.rule.src_groups') }}</label>
<MultiSelect v-model="rule.source_groups" :options="props.groupNames" multiple fluid filter
:placeholder="t('acl.rule.src_groups')" />
</div>
<div class="flex flex-col gap-2">
<label class="font-bold">{{ t('acl.rule.dst_groups') }}</label>
<MultiSelect v-model="rule.destination_groups" :options="props.groupNames" multiple fluid filter
:placeholder="t('acl.rule.dst_groups')" />
</div>
</div>
</Panel>
</div>
<template #footer>
<Button :label="t('web.common.cancel')" icon="pi pi-times" @click="close" text />
<Button :label="t('web.common.save')" icon="pi pi-save" @click="save" />
</template>
</Dialog>
</template>
+88 -2
View File
@@ -3,6 +3,14 @@ networking_method: 网络方式
public_server: 公共服务器
manual: 手动
standalone: 独立
initial_nodes: 初始节点
initial_nodes_help: |
EasyTier 不分服务端/客户端。
• 填“初始节点” = 插上网线,直接加入已有网络。
• 留空 = 节点独立启动,等别人来连,或你后续手动连。
• 无论直接还是间接连通(通过其他节点搭桥),都能组网互通。
初始节点可以用自己的,也可以用别人分享的。
initial_node_placeholder: 例如:node.example.com
virtual_ipv4: 虚拟IPv4地址
virtual_ipv4_dhcp: DHCP
network_name: 网络名称
@@ -18,12 +26,17 @@ advanced_settings: 高级设置
basic_settings: 基础设置
listener_urls: 监听地址
rpc_port: RPC端口
port: 端口
rpc_portal_whitelists: RPC白名单
config_network: 配置网络
running: 运行中
error_msg: 错误信息
detail: 详情
add_new_network: 添加新网络
add_peer_url: 添加节点
add_initial_node: 添加初始节点
add_listener_url: 添加监听地址
add_mapped_listener: 添加监听映射
del_cur_network: 删除当前网络
select_network: 选择网络
network_instances: 网络实例
@@ -91,6 +104,9 @@ use_smoltcp_help: 使用用户态 TCP/IP 协议栈,避免操作系统防火墙
disable_ipv6: 禁用IPv6
disable_ipv6_help: 禁用此节点的IPv6功能,仅使用IPv4进行网络通信。
ipv6_public_addr_auto: 自动获取公网 IPv6
ipv6_public_addr_auto_help: 自动从共享了 IPv6 子网的对等节点获取一个公网 IPv6 地址。
enable_kcp_proxy: 启用 KCP 代理
enable_kcp_proxy_help: 将 TCP 流量转为 KCP 流量,降低传输延迟,提升传输速度。
@@ -104,11 +120,14 @@ disable_quic_input: 禁用 QUIC 输入
disable_quic_input_help: 禁用 QUIC 入站流量,其他开启 QUIC 代理的节点仍然使用 TCP 连接到本节点。
disable_p2p: 禁用 P2P
disable_p2p_help: 禁用 P2P 模式,所有流量通过手动指定的服务器中转
disable_p2p_help: 禁用普通自动 P2P。开启 need-p2p 的节点仍可与当前节点建立 P2P
p2p_only: 仅 P2P
p2p_only_help: 仅与已经建立P2P连接的对等节点通信,不通过其他节点中转。
lazy_p2p: 延迟 P2P
lazy_p2p_help: 仅在实际流量需要某个对等节点时才尝试建立 P2P。开启 need-p2p 的节点仍会被主动连接。
bind_device: 仅使用物理网卡
bind_device_help: 仅使用物理网卡,避免 EasyTier 通过其他虚拟网建立连接。
@@ -123,6 +142,9 @@ relay_all_peer_rpc_help: |
允许转发所有对等节点的RPC数据包,即使对等节点不在转发网络白名单中。
这可以帮助白名单外网络中的对等节点建立P2P连接。
need_p2p: 需要 P2P
need_p2p_help: 即使其他节点启用了 lazy p2p,也要求它们主动与当前节点建立 P2P 连接。
multi_thread: 启用多线程
multi_thread_help: 使用多线程运行时
@@ -130,7 +152,7 @@ proxy_forward_by_system: 系统转发
proxy_forward_by_system_help: 通过系统内核转发子网代理数据包,禁用内置NAT
disable_encryption: 禁用加密
disable_encryption_help: 禁用对等节点通信的加密,默认为false,必须与对等节点相同
disable_encryption_help: 禁用对等节点通信的加密。注意:默认启用加密,若勾选此项则关闭,必须与对等节点设置一致。
disable_tcp_hole_punching: 禁用TCP打洞
disable_tcp_hole_punching_help: 禁用TCP打洞功能
@@ -138,6 +160,12 @@ disable_tcp_hole_punching_help: 禁用TCP打洞功能
disable_udp_hole_punching: 禁用UDP打洞
disable_udp_hole_punching_help: 禁用UDP打洞功能
enable_udp_broadcast_relay: UDP 广播中继
enable_udp_broadcast_relay_help: "仅 Windows:捕获物理网卡上的本机 UDP 广播包并转发给 EasyTier 对等节点,帮助局域网游戏发现房间。需要管理员权限。"
disable_upnp: 禁用 UPnP
disable_upnp_help: 禁用符合条件监听器的运行时 UPnP/NAT-PMP 端口映射;自动端口映射默认开启。
disable_sym_hole_punching: 禁用对称NAT打洞
disable_sym_hole_punching_help: 禁用对称NAT的打洞(生日攻击),将对称NAT视为锥形NAT处理
@@ -177,6 +205,12 @@ mtu_help: |
TUN设备的MTU,默认为非加密时为1380,加密时为1360。范围:400-1380
mtu_placeholder: 留空为默认值1380
instance_recv_bps_limit: 实例接收限速
instance_recv_bps_limit_help: |
限制当前实例整体入站流量的总接收速率,单位为字节每秒。
留空表示不限速。
instance_recv_bps_limit_placeholder: 留空表示不限速
mapped_listeners: 监听映射
mapped_listeners_help: |
手动指定监听器的公网地址,其他节点可以使用该地址连接到本节点。
@@ -229,6 +263,7 @@ event:
DhcpIpv4Conflicted: DHCP IPv4地址冲突
PortForwardAdded: 端口转发添加
ProxyCidrsUpdated: 子网代理CIDR更新
UdpBroadcastRelayStartResult: UDP广播中继启动结果
web:
login:
@@ -242,6 +277,7 @@ web:
captcha: 验证码
back_to_login: 返回登录
login: 登录
sso_login: "SSO 登录"
register:
title: 注册
@@ -329,11 +365,15 @@ web:
delete: 删除
edit: 编辑
refresh: 刷新
add: 添加
loading: 加载中...
error: 错误
success: 成功
warning: 警告
info: 提示
enable: 开启
disable: 关闭
address: 地址
settings:
title: 设置
@@ -350,6 +390,8 @@ mode:
switch_mode: 切换模式
config_dir: 配置目录
rpc_portal: RPC端口
enable_rpc_tcp_listen: 开启 RPC 端口监听(TCP
rpc_listen_port: RPC 监听端口
log_level: 日志级别
log_dir: 日志目录
remote_rpc_address: 远程RPC地址
@@ -370,6 +412,7 @@ mode:
stop_service_success: 服务停止成功
remote_rpc_address_empty: 远程RPC地址不能为空
service_config_empty: 服务配置不能为空
rpc_connection_failed: "RPC 连接失败:{error}"
config-server:
title: 配置服务器
@@ -390,3 +433,46 @@ config-server:
client:
not_running: 无法连接至远程客户端
retry: 重试
acl:
title: 访问控制
help: 访问控制列表,用于限制节点间的通信。
enabled: 启用 ACL
default_action: 默认动作
chains: 规则链
inbound: 入站
outbound: 出站
forward: 转发
rules: 规则
add_rule: 添加规则
edit_rule: 编辑规则
rule:
name: 规则名称
description: 描述
enabled: 启用
protocol: 协议
action: 动作
src_ips: 来源 IP
dst_ips: 目的 IP
src_ports: 来源端口
dst_ports: 目的端口
rate_limit: 速率限制 (pps)
burst_limit: 爆发限制
stateful: 状态追踪
src_groups: 来源组
dst_groups: 目的组
groups: 组管理
group:
declares: 声明组
members: 加入组
name: 组名
secret: 密钥
help: 在此处定义网络中的组身份,以便在规则中使用。
any: 任意
allow: 允许
drop: 丢弃
delete_chain_confirm: 确定要删除此规则链及其所有规则吗?
chain:
name: 名称
type: 类型
match: 匹配
+88 -2
View File
@@ -3,6 +3,14 @@ networking_method: Networking Method
public_server: Public Server
manual: Manual
standalone: Standalone
initial_nodes: Initial Nodes
initial_nodes_help: |
EasyTier does not distinguish between server and client roles.
• Filling in Initial Nodes = plugging in the cable and joining an existing network.
• Leaving it empty = the node starts alone until others connect to it, or you connect it later yourself.
• Direct or indirect connectivity, including through relay nodes, can form one network.
Initial nodes can be your own nodes or ones shared by others.
initial_node_placeholder: "Example: node.example.com"
virtual_ipv4: Virtual IPv4
virtual_ipv4_dhcp: DHCP
network_name: Network Name
@@ -18,12 +26,17 @@ advanced_settings: Advanced Settings
basic_settings: Basic Settings
listener_urls: Listener URLs
rpc_port: RPC Port
port: Port
rpc_portal_whitelists: RPC Whitelist
config_network: Config Network
running: Running
error_msg: Error Message
detail: Detail
add_new_network: New Network
add_peer_url: Add Peer
add_initial_node: Add Initial Node
add_listener_url: Add Listener
add_mapped_listener: Add Mapped Listener
del_cur_network: Delete Current Network
select_network: Select Network
network_instances: Network Instances
@@ -90,6 +103,9 @@ use_smoltcp_help: Use a user-space TCP/IP stack to avoid issues with operating s
disable_ipv6: Disable IPv6
disable_ipv6_help: Disable IPv6 functionality for this node, only use IPv4 for network communication.
ipv6_public_addr_auto: Auto Public IPv6
ipv6_public_addr_auto_help: Auto-obtain a public IPv6 address from a peer that shares its IPv6 subnet.
enable_kcp_proxy: Enable KCP Proxy
enable_kcp_proxy_help: Convert TCP traffic to KCP traffic to reduce latency and boost transmission speed.
@@ -103,11 +119,14 @@ disable_quic_input: Disable QUIC Input
disable_quic_input_help: Disable inbound QUIC traffic, while nodes with QUIC proxy enabled continue to connect using TCP.
disable_p2p: Disable P2P
disable_p2p_help: Disable P2P mode; route all traffic through a manually specified relay server.
disable_p2p_help: Disable ordinary automatic P2P. Nodes with need-p2p enabled can still establish P2P with this node.
p2p_only: P2P Only
p2p_only_help: Only communicate with peers that have already established P2P connections, do not relay through other nodes.
lazy_p2p: Lazy P2P
lazy_p2p_help: Only try to establish P2P when traffic actually targets a peer. Peers with need-p2p enabled are still connected proactively.
bind_device: Bind to Physical Device Only
bind_device_help: Use only the physical network interface to prevent EasyTier from connecting via virtual networks.
@@ -122,6 +141,9 @@ relay_all_peer_rpc_help: |
Relay all peer rpc packets, even if the peer is not in the relay network whitelist.
This can help peers not in relay network whitelist to establish p2p connection.
need_p2p: Need P2P
need_p2p_help: Ask other peers to proactively establish P2P connections to this node even when they enable lazy P2P.
multi_thread: Multi Thread
multi_thread_help: Use multi-thread runtime
@@ -129,7 +151,7 @@ proxy_forward_by_system: System Forward
proxy_forward_by_system_help: Forward packet to proxy networks via system kernel, disable internal nat for network proxy
disable_encryption: Disable Encryption
disable_encryption_help: Disable encryption for peers communication, default is false, must be same with peers
disable_encryption_help: Disable encryption for peers communication. Encryption is enabled by default, this option must be same with peers.
disable_tcp_hole_punching: Disable TCP Hole Punching
disable_tcp_hole_punching_help: Disable tcp hole punching
@@ -137,6 +159,12 @@ disable_tcp_hole_punching_help: Disable tcp hole punching
disable_udp_hole_punching: Disable UDP Hole Punching
disable_udp_hole_punching_help: Disable udp hole punching
enable_udp_broadcast_relay: UDP Broadcast Relay
enable_udp_broadcast_relay_help: "Windows only: capture local UDP broadcast packets from physical interfaces and forward them to EasyTier peers. Helps games to find rooms in local network. Requires administrator privileges."
disable_upnp: Disable UPnP
disable_upnp_help: Disable runtime UPnP/NAT-PMP port mapping for eligible listeners; automatic port mapping is enabled by default.
disable_sym_hole_punching: Disable Symmetric NAT Hole Punching
disable_sym_hole_punching_help: Disable special hole punching handling for symmetric NAT (based on birthday attack), treat symmetric NAT as cone NAT
@@ -177,6 +205,12 @@ mtu_help: |
MTU of the TUN device, default is 1380 for non-encryption, 1360 for encryption. Range:400-1380
mtu_placeholder: Leave blank as default value 1380
instance_recv_bps_limit: Instance Receive Limit
instance_recv_bps_limit_help: |
Limit the total receive bandwidth for the whole instance. Unit: bytes per second.
Leave blank for no limit.
instance_recv_bps_limit_placeholder: Leave blank for no limit
mapped_listeners: Map Listeners
mapped_listeners_help: |
Manually specify the public address of the listener, other nodes can use this address to connect to this node.
@@ -229,6 +263,7 @@ event:
DhcpIpv4Conflicted: DhcpIpv4Conflicted
PortForwardAdded: PortForwardAdded
ProxyCidrsUpdated: ProxyCidrsUpdated
UdpBroadcastRelayStartResult: UDP Broadcast Relay Start Result
web:
login:
@@ -242,6 +277,7 @@ web:
captcha: Captcha
back_to_login: Back to Login
login: Login
sso_login: "SSO Login"
register:
title: Register
@@ -329,11 +365,15 @@ web:
delete: Delete
edit: Edit
refresh: Refresh
add: Add
loading: Loading...
error: Error
success: Success
warning: Warning
info: Info
enable: Enable
disable: Disable
address: Address
settings:
title: Settings
@@ -350,6 +390,8 @@ mode:
switch_mode: Switch Mode
config_dir: Config Dir
rpc_portal: RPC Portal
enable_rpc_tcp_listen: Enable RPC port listening (TCP)
rpc_listen_port: RPC Listen Port
log_level: Log Level
log_dir: Log Dir
remote_rpc_address: Remote RPC Address
@@ -370,6 +412,7 @@ mode:
stop_service_success: Service stopped successfully
remote_rpc_address_empty: Remote RPC Address cannot be empty
service_config_empty: Service Config cannot be empty
rpc_connection_failed: "RPC connection failed: {error}"
config-server:
title: Config Server
@@ -390,3 +433,46 @@ config-server:
client:
not_running: Unable to connect to remote client.
retry: Retry
acl:
title: Access Control (ACL)
help: Access control list to restrict communication between nodes.
enabled: Enable ACL
default_action: Default Action
chains: Rule Chains
inbound: Inbound
outbound: Outbound
forward: Forward
rules: Rules
add_rule: Add Rule
edit_rule: Edit Rule
rule:
name: Rule Name
description: Description
enabled: Enabled
protocol: Protocol
action: Action
src_ips: Source IPs
dst_ips: Destination IPs
src_ports: Source Ports
dst_ports: Destination Ports
rate_limit: Rate Limit (pps)
burst_limit: Burst Limit
stateful: Stateful
src_groups: Source Groups
dst_groups: Destination Groups
groups: Groups
group:
declares: Declared Groups
members: Node Memberships
name: Group Name
secret: Group Secret
help: Define group identities in the network to use them in rules.
any: Any
allow: Allow
drop: Drop
delete_chain_confirm: Are you sure you want to delete this rule chain and all its rules?
chain:
name: Name
type: Type
match: Match
+3 -1
View File
@@ -49,4 +49,6 @@
.v-popper__inner {
white-space: pre-wrap;
}
max-width: 32rem;
line-height: 1.5;
}
+140 -4
View File
@@ -6,6 +6,82 @@ export enum NetworkingMethod {
Standalone = 2,
}
export interface SecureModeConfig {
enabled: boolean
// Keep protocol compatibility with backend/import-export flows even though the GUI
// does not render secure-mode or credential inputs.
local_private_key?: string
local_public_key?: string
}
export enum AclProtocol {
Unspecified = 0,
TCP = 1,
UDP = 2,
ICMP = 3,
ICMPv6 = 4,
Any = 5,
}
export enum AclAction {
Noop = 0,
Allow = 1,
Drop = 2,
}
export enum AclChainType {
UnspecifiedChain = 0,
Inbound = 1,
Outbound = 2,
Forward = 3,
}
export interface AclRule {
name: string
description: string
priority: number
enabled: boolean
protocol: AclProtocol
ports: string[]
source_ips: string[]
destination_ips: string[]
source_ports: string[]
action: AclAction
rate_limit: number
burst_limit: number
stateful: boolean
source_groups: string[]
destination_groups: string[]
}
export interface AclChain {
name: string
chain_type: AclChainType
description: string
enabled: boolean
rules: AclRule[]
default_action: AclAction
}
export interface GroupIdentity {
group_name: string
group_secret: string
}
export interface GroupInfo {
declares: GroupIdentity[]
members: string[]
}
export interface AclV1 {
chains: AclChain[]
group?: GroupInfo
}
export interface Acl {
acl_v1?: AclV1
}
export interface NetworkConfig {
instance_id: string
@@ -14,7 +90,9 @@ export interface NetworkConfig {
network_length: number
hostname?: string
network_name: string
network_secret: string
network_secret?: string
credential_file?: string
secure_mode?: SecureModeConfig
networking_method: NetworkingMethod
@@ -37,21 +115,26 @@ export interface NetworkConfig {
use_smoltcp?: boolean
disable_ipv6?: boolean
ipv6_public_addr_auto?: boolean
enable_kcp_proxy?: boolean
disable_kcp_input?: boolean
enable_quic_proxy?: boolean
disable_quic_input?: boolean
disable_p2p?: boolean
p2p_only?: boolean
lazy_p2p?: boolean
bind_device?: boolean
no_tun?: boolean
enable_exit_node?: boolean
relay_all_peer_rpc?: boolean
need_p2p?: boolean
multi_thread?: boolean
proxy_forward_by_system?: boolean
disable_encryption?: boolean
disable_tcp_hole_punching?: boolean
disable_udp_hole_punching?: boolean
disable_upnp?: boolean
enable_udp_broadcast_relay?: boolean
disable_sym_hole_punching?: boolean
enable_relay_network_whitelist?: boolean
@@ -66,12 +149,14 @@ export interface NetworkConfig {
socks5_port: number
mtu: number | null
instance_recv_bps_limit: number | null
mapped_listeners: string[]
enable_magic_dns?: boolean
enable_private_mode?: boolean
port_forwards: PortForwardConfig[]
acl?: Acl
}
export function DEFAULT_NETWORK_CONFIG(): NetworkConfig {
@@ -83,10 +168,10 @@ export function DEFAULT_NETWORK_CONFIG(): NetworkConfig {
network_length: 24,
network_name: 'easytier',
network_secret: '',
credential_file: '',
networking_method: NetworkingMethod.PublicServer,
public_server_url: 'tcp://public.easytier.top:11010',
networking_method: NetworkingMethod.Manual,
public_server_url: '',
peer_urls: [],
proxy_cidrs: [],
@@ -108,21 +193,26 @@ export function DEFAULT_NETWORK_CONFIG(): NetworkConfig {
use_smoltcp: false,
disable_ipv6: false,
ipv6_public_addr_auto: false,
enable_kcp_proxy: false,
disable_kcp_input: false,
enable_quic_proxy: false,
disable_quic_input: false,
disable_p2p: false,
p2p_only: false,
lazy_p2p: false,
bind_device: true,
no_tun: false,
enable_exit_node: false,
relay_all_peer_rpc: false,
need_p2p: false,
multi_thread: true,
proxy_forward_by_system: false,
disable_encryption: false,
disable_tcp_hole_punching: false,
disable_udp_hole_punching: false,
disable_upnp: false,
enable_udp_broadcast_relay: false,
disable_sym_hole_punching: false,
enable_relay_network_whitelist: false,
relay_network_whitelist: [],
@@ -132,13 +222,56 @@ export function DEFAULT_NETWORK_CONFIG(): NetworkConfig {
enable_socks5: false,
socks5_port: 1080,
mtu: null,
instance_recv_bps_limit: null,
mapped_listeners: [],
enable_magic_dns: false,
enable_private_mode: false,
port_forwards: [],
acl: {
acl_v1: {
group: {
declares: [],
members: [],
},
chains: [],
},
},
}
}
function cleanPeerUrls(urls: string[] | undefined): string[] {
return (urls ?? []).map((url) => url.trim()).filter((url) => url.length > 0)
}
export function normalizeNetworkConfig(config: NetworkConfig): NetworkConfig {
const normalized: NetworkConfig = {
...config,
peer_urls: cleanPeerUrls(config.peer_urls),
}
const publicServerUrl = normalized.public_server_url?.trim() ?? ''
switch (normalized.networking_method) {
case NetworkingMethod.PublicServer:
normalized.peer_urls = publicServerUrl ? [publicServerUrl] : []
break
case NetworkingMethod.Manual:
break
case NetworkingMethod.Standalone:
default:
normalized.peer_urls = []
break
}
normalized.networking_method = NetworkingMethod.Manual
normalized.public_server_url = ''
return normalized
}
export function toBackendNetworkConfig(config: NetworkConfig): NetworkConfig {
return normalizeNetworkConfig(config)
}
export interface NetworkInstance {
instance_id: string
@@ -204,6 +337,7 @@ export interface NodeInfo {
stun_info: StunInfo
listeners: Url[]
vpn_portal_cfg?: string
peer_id: number
}
export interface StunInfo {
@@ -315,4 +449,6 @@ export enum EventType {
PortForwardAdded = 'PortForwardAdded', // PortForwardConfigPb
ProxyCidrsUpdated = 'ProxyCidrsUpdated', // string[], string[]
UdpBroadcastRelayStartResult = 'UdpBroadcastRelayStartResult', // { capture_backend?: string, error?: string }
}
+2 -2
View File
@@ -11,7 +11,7 @@
"dependencies": {
"@modyfi/vite-plugin-yaml": "^1.1.0",
"@primeuix/themes": "^1.2.3",
"axios": "^1.7.7",
"axios": "^1.13.5",
"easytier-frontend-lib": "workspace:*",
"primevue": "^4.3.9",
"tailwindcss-primeui": "^0.3.4",
@@ -28,7 +28,7 @@
"postcss": "^8.4.47",
"tailwindcss": "=3.4.17",
"typescript": "~5.6.2",
"vite": "^5.4.10",
"vite": "^5.4.21",
"vite-plugin-singlefile": "^2.0.3",
"vue-tsc": "^2.1.10"
}
+42 -3
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { Card, InputText, Password, Button, AutoComplete } from 'primevue';
import { useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
@@ -68,8 +68,43 @@ const apiHostSearch = async (event: { query: string }) => {
});
}
onMounted(() => {
const oidcEnabled = ref(false);
const lastCheckedHost = ref('');
const oidcCheckTimer = ref<ReturnType<typeof setTimeout> | null>(null);
const checkOidcConfig = () => {
if (oidcCheckTimer.value) clearTimeout(oidcCheckTimer.value);
oidcCheckTimer.value = setTimeout(async () => {
const host = apiHost.value;
if (host === lastCheckedHost.value) return;
const enabled = (await new ApiClient(host).getOidcConfig()).enabled;
// If host changes while request is in-flight, do not overwrite UI state.
if (apiHost.value !== host) return;
lastCheckedHost.value = host;
oidcEnabled.value = enabled;
}, 300);
};
watch(apiHost, () => {
checkOidcConfig();
});
const onSsoLogin = () => {
saveApiHost(apiHost.value);
localStorage.setItem('apiHost', btoa(apiHost.value));
window.location.href = api.value.oidcLoginUrl();
};
onMounted(() => {
checkOidcConfig();
});
onBeforeUnmount(() => {
if (oidcCheckTimer.value) {
clearTimeout(oidcCheckTimer.value);
oidcCheckTimer.value = null;
}
});
</script>
@@ -104,6 +139,10 @@ onMounted(() => {
<Button :label="t('web.login.register')" type="button" class="w-full"
@click="saveApiHost(apiHost); $router.replace({ name: 'register' })" severity="secondary" />
</div>
<div v-if="oidcEnabled" class="flex items-center justify-between">
<Button :label="t('web.login.sso_login')" type="button" class="w-full" severity="info"
@click="onSsoLogin" />
</div>
</form>
<form v-else @submit.prevent="onRegister" class="space-y-4">
@@ -144,4 +183,4 @@ onMounted(() => {
</div>
</template>
<style scoped></style>
<style scoped></style>
+31 -7
View File
@@ -1,11 +1,15 @@
import axios, { AxiosError, AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import { type Api, type NetworkTypes, Utils } from 'easytier-frontend-lib';
import { type Api, NetworkTypes, Utils } from 'easytier-frontend-lib';
import { Md5 } from 'ts-md5';
export interface ValidateConfigResponse {
toml_config: string;
}
export interface OidcConfigResponse {
enabled: boolean;
}
// 定义接口返回的数据结构
export interface LoginResponse {
success: boolean;
@@ -174,6 +178,19 @@ export class ApiClient {
return this.client.defaults.baseURL + '/auth/captcha';
}
public async getOidcConfig(): Promise<OidcConfigResponse> {
try {
const response = await this.client.get<any, OidcConfigResponse>('/auth/oidc/config');
return response;
} catch (error) {
return { enabled: false };
}
}
public oidcLoginUrl() {
return this.client.defaults.baseURL + '/auth/oidc/login';
}
public get_remote_client(machine_id: string): Api.RemoteClient {
return new WebRemoteClient(machine_id, this.client);
}
@@ -189,13 +206,13 @@ class WebRemoteClient implements Api.RemoteClient {
}
async validate_config(config: NetworkTypes.NetworkConfig): Promise<Api.ValidateConfigResponse> {
const response = await this.client.post<NetworkTypes.NetworkConfig, ValidateConfigResponse>(`/machines/${this.machine_id}/validate-config`, {
config: config,
config: NetworkTypes.toBackendNetworkConfig(config),
});
return response;
}
async run_network(config: NetworkTypes.NetworkConfig, save: boolean): Promise<undefined> {
await this.client.post<string>(`/machines/${this.machine_id}/networks`, {
config: config,
config: NetworkTypes.toBackendNetworkConfig(config),
save: save
});
}
@@ -216,15 +233,19 @@ class WebRemoteClient implements Api.RemoteClient {
});
}
async save_config(config: NetworkTypes.NetworkConfig): Promise<undefined> {
await this.client.put(`/machines/${this.machine_id}/networks/config/${config.instance_id}`, { config });
await this.client.put(`/machines/${this.machine_id}/networks/config/${config.instance_id}`, {
config: NetworkTypes.toBackendNetworkConfig(config)
});
}
async get_network_config(inst_id: string): Promise<NetworkTypes.NetworkConfig> {
const response = await this.client.get<any, NetworkTypes.NetworkConfig>('/machines/' + this.machine_id + '/networks/config/' + inst_id);
return response;
return NetworkTypes.normalizeNetworkConfig(response);
}
async generate_config(config: NetworkTypes.NetworkConfig): Promise<Api.GenerateConfigResponse> {
try {
const response = await this.client.post<any, GenerateConfigResponse>('/generate-config', { config });
const response = await this.client.post<any, GenerateConfigResponse>('/generate-config', {
config: NetworkTypes.toBackendNetworkConfig(config)
});
return response;
} catch (error) {
if (error instanceof AxiosError) {
@@ -236,6 +257,9 @@ class WebRemoteClient implements Api.RemoteClient {
async parse_config(toml_config: string): Promise<Api.ParseConfigResponse> {
try {
const response = await this.client.post<any, ParseConfigResponse>('/parse-config', { toml_config });
if (response.config) {
response.config = NetworkTypes.normalizeNetworkConfig(response.config);
}
return response;
} catch (error) {
if (error instanceof AxiosError) {
@@ -252,4 +276,4 @@ class WebRemoteClient implements Api.RemoteClient {
}
}
export default ApiClient;
export default ApiClient;
+39 -3
View File
@@ -17,14 +17,20 @@ cli:
en: "The port to listen for the config server, used by the easytier-core to connect to"
zh-CN: "配置服务器的监听端口,用于被 easytier-core 连接"
config_server_protocol:
en: "The protocol to listen for the config server, used by the easytier-core to connect to"
zh-CN: "配置服务器的监听协议,用于被 easytier-core 连接, 可能的值:udp, tcp"
en: "The protocol to listen for the config server, used by the easytier-core to connect to, possible values: udp, tcp, ws"
zh-CN: "配置服务器的监听协议,用于被 easytier-core 连接, 可能的值:udp, tcp, ws"
api_server_port:
en: "The port to listen for the restful server, acting as ApiHost and used by the web frontend"
zh-CN: "restful 服务器的监听端口,作为 ApiHost 并被 web 前端使用"
api_server_addr:
en: "The listen address for the restful server, e.g. 0.0.0.0, ::, 127.0.0.1"
zh-CN: "restful 服务器的监听地址, 例如 0.0.0.0, ::, 127.0.0.1"
web_server_port:
en: "The port to listen for the web dashboard server, default is same as the api server port"
zh-CN: "web dashboard 服务器的监听端口, 默认为与 api 服务器端口相同"
web_server_addr:
en: "The listen address for the web dashboard server (only effective when web_server_port differs from api_server_port or web_server_addr differs from api_server_addr), e.g. 0.0.0.0, ::, 127.0.0.1"
zh-CN: "web dashboard 服务器的监听地址(仅在 web_server_port 与 api_server_port 不同,或 web_server_addr 与 api_server_addr 不同时生效), 例如 0.0.0.0, ::, 127.0.0.1"
no_web:
en: "Do not run the web dashboard server"
zh-CN: "不运行 web dashboard 服务器"
@@ -33,4 +39,34 @@ cli:
zh-CN: "API 服务器的 URL,用于 web 前端连接"
geoip_db:
en: "The path to the GeoIP2 database file, used to lookup the location of the client, default is the embedded file (only country information) , recommend https://github.com/P3TERX/GeoLite.mmdb"
zh-CN: "GeoIP2 数据库文件路径,用于查找客户端的位置,默认为嵌入文件(仅国家信息),推荐 https://github.com/P3TERX/GeoLite.mmdb"
zh-CN: "GeoIP2 数据库文件路径,用于查找客户端的位置,默认为嵌入文件(仅国家信息),推荐 https://github.com/P3TERX/GeoLite.mmdb"
disable_registration:
en: "Disable user registration"
zh-CN: "禁用用户注册"
oidc_issuer_url:
en: "The OIDC issuer URL for single sign-on authentication"
zh-CN: "OIDC 签发者 URL,用于单点登录认证"
oidc_client_id:
en: "The OIDC client ID"
zh-CN: "OIDC 客户端 ID"
oidc_client_secret:
en: "The OIDC client secret (can also be set via OIDC_CLIENT_SECRET env var)"
zh-CN: "OIDC 客户端密钥(也可通过 OIDC_CLIENT_SECRET 环境变量设置)"
oidc_username_claim:
en: "The OIDC claim to use as the local username, default: preferred_username"
zh-CN: "用作本地用户名的 OIDC claim 字段,默认: preferred_username"
oidc_scopes:
en: "OIDC scopes to request during login. Supports comma-separated values or repeated --oidc-scopes flags, default: openid,profile"
zh-CN: "登录时请求的 OIDC scopes。支持逗号分隔或多次指定 --oidc-scopes,默认: openid,profile"
oidc_redirect_url:
en: "The OIDC redirect URL (callback URL), must match exactly what is registered with your Identity Provider. Required when using OIDC. Example: http://your-domain.com:11211/api/v1/auth/oidc/callback"
zh-CN: "OIDC 重定向 URL(回调 URL),必须与身份提供商注册的地址完全一致。使用 OIDC 时必须提供。示例: http://your-domain.com:11211/api/v1/auth/oidc/callback"
allow_auto_create_user:
en: "Allow auto-creating local user when easytier-core connects with an unknown username"
zh-CN: "当 easytier-core 使用未知用户名连接时,允许自动创建本地用户"
oidc_disable_pkce:
en: "Disable PKCE (Proof Key for Code Exchange) for OIDC authentication"
zh-CN: "禁用 OIDC 认证的 PKCE(授权码交换证明密钥)"
oidc_frontend_base_url:
en: "Frontend base URL to redirect to after successful OIDC callback. Required when frontend and API are deployed separately (non-embed build, --no-web mode, or different web_server_port)"
zh-CN: "OIDC 回调成功后跳转的前端入口地址。当前端与 API 分离部署时必须提供(非 embed 构建、--no-web 模式、或 web_server_port 与 api_server_port 不同)"
+93 -19
View File
@@ -2,8 +2,8 @@ pub mod session;
pub mod storage;
use std::sync::{
atomic::{AtomicU32, Ordering},
Arc,
atomic::{AtomicU32, Ordering},
};
use dashmap::DashMap;
@@ -13,13 +13,17 @@ use easytier::{
},
rpc_service::remote_client::{self, RemoteClientManager},
tunnel::TunnelListener,
web_client::security,
};
use maxminddb::geoip2;
use session::{Location, Session};
use storage::{Storage, StorageToken};
use crate::FeatureFlags;
use crate::webhook::SharedWebhookConfig;
use tokio::task::JoinSet;
use crate::db::{entity::user_running_network_configs, Db, UserIdInDb};
use crate::db::{Db, UserIdInDb, entity::user_running_network_configs};
#[derive(rust_embed::Embed)]
#[folder = "resources/"]
@@ -55,11 +59,19 @@ pub struct ClientManager {
client_sessions: Arc<DashMap<url::Url, Arc<Session>>>,
storage: Storage,
feature_flags: Arc<FeatureFlags>,
webhook_config: SharedWebhookConfig,
geoip_db: Arc<Option<maxminddb::Reader<Vec<u8>>>>,
}
impl ClientManager {
pub fn new(db: Db, geoip_db: Option<String>) -> Self {
pub fn new(
db: Db,
geoip_db: Option<String>,
feature_flags: Arc<FeatureFlags>,
webhook_config: SharedWebhookConfig,
) -> Self {
let client_sessions = Arc::new(DashMap::new());
let sessions: Arc<DashMap<url::Url, Arc<Session>>> = client_sessions.clone();
let mut tasks = JoinSet::new();
@@ -76,6 +88,9 @@ impl ClientManager {
client_sessions,
storage: Storage::new(db),
feature_flags,
webhook_config,
geoip_db: Arc::new(load_geoip_db(geoip_db)),
}
}
@@ -90,17 +105,33 @@ impl ClientManager {
let storage = self.storage.weak_ref();
let listeners_cnt = self.listeners_cnt.clone();
let geoip_db = self.geoip_db.clone();
let feature_flags = self.feature_flags.clone();
let webhook_config = self.webhook_config.clone();
self.tasks.spawn(async move {
while let Ok(tunnel) = listener.accept().await {
let (tunnel, secure) = match security::accept_or_upgrade_server_tunnel(tunnel).await {
Ok(v) => v,
Err(error) => {
tracing::warn!(%error, "failed to accept secure tunnel, dropping connection");
continue;
}
};
let info = tunnel.info().unwrap();
let client_url: url::Url = info.remote_addr.unwrap().into();
let location = Self::lookup_location(&client_url, geoip_db.clone());
tracing::info!(
"New session from {:?}, location: {:?}",
"New session from {:?}, secure: {}, location: {:?}",
client_url,
secure,
location
);
let mut session = Session::new(storage.clone(), client_url.clone(), location);
let mut session = Session::new(
storage.clone(),
client_url.clone(),
location,
feature_flags.clone(),
webhook_config.clone(),
);
session.serve(tunnel).await;
sessions.insert(client_url, Arc::new(session));
}
@@ -144,6 +175,24 @@ impl ClientManager {
.map(|item| item.value().clone())
}
pub async fn disconnect_session_by_machine_id(
&self,
user_id: UserIdInDb,
machine_id: &uuid::Uuid,
) -> bool {
let Some(client_url) = self
.storage
.get_client_url_by_machine_id(user_id, machine_id)
else {
return false;
};
let Some((_, session)) = self.client_sessions.remove(&client_url) else {
return false;
};
session.stop().await;
true
}
pub async fn list_machine_by_user_id(&self, user_id: UserIdInDb) -> Vec<url::Url> {
self.storage.list_user_clients(user_id)
}
@@ -291,12 +340,19 @@ mod tests {
};
use sqlx::Executor;
use crate::{client_manager::ClientManager, db::Db};
use crate::{FeatureFlags, client_manager::ClientManager, db::Db};
#[tokio::test]
async fn test_client() {
let listener = UdpTunnelListener::new("udp://0.0.0.0:54333".parse().unwrap());
let mut mgr = ClientManager::new(Db::memory_db().await, None);
let mut mgr = ClientManager::new(
Db::memory_db().await,
None,
Arc::new(FeatureFlags::default()),
Arc::new(crate::webhook::WebhookConfig::new(
None, None, None, None, None,
)),
);
mgr.add_listener(Box::new(listener)).await.unwrap();
mgr.db()
@@ -309,27 +365,45 @@ mod tests {
let _c = WebClient::new(
connector,
"test",
uuid::Uuid::new_v4(),
"test",
false,
Arc::new(NetworkInstanceManager::new()),
None,
);
wait_for_condition(
|| async { mgr.client_sessions.len() == 1 },
Duration::from_secs(6),
|| async { !mgr.client_sessions.is_empty() },
Duration::from_secs(12),
)
.await;
let mut a = mgr
.client_sessions
.iter()
.next()
.unwrap()
.data()
.read()
.await
.heartbeat_waiter();
let req = a.recv().await.unwrap();
let req = tokio::time::timeout(Duration::from_secs(12), async {
loop {
let sessions = mgr
.client_sessions
.iter()
.map(|item| item.value().clone())
.collect::<Vec<_>>();
if sessions.is_empty() {
tokio::time::sleep(Duration::from_millis(100)).await;
continue;
}
let mut found_req = None;
for session in sessions {
if let Some(req) = session.data().read().await.req() {
found_req = Some(req);
break;
}
}
if let Some(req) = found_req {
break req;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
})
.await
.unwrap();
println!("{:?}", req);
println!("{:?}", mgr);
}
File diff suppressed because it is too large Load Diff
+65 -14
View File
@@ -21,7 +21,6 @@ struct ClientInfo {
#[derive(Debug)]
pub struct StorageInner {
// some map for indexing
user_clients_map: DashMap<UserIdInDb, DashMap<uuid::Uuid, ClientInfo>>,
pub db: Db,
}
@@ -46,18 +45,14 @@ impl Storage {
}))
}
fn remove_mid_to_client_info_map(
map: &DashMap<uuid::Uuid, ClientInfo>,
machine_id: &uuid::Uuid,
client_url: &url::Url,
) {
map.remove_if(machine_id, |_, v| v.storage_token.client_url == *client_url);
fn remove_client_info_map(map: &DashMap<uuid::Uuid, ClientInfo>, stoken: &StorageToken) {
map.remove_if(&stoken.machine_id, |_, v| {
v.storage_token.client_url == stoken.client_url
&& v.storage_token.user_id == stoken.user_id
});
}
fn update_mid_to_client_info_map(
map: &DashMap<uuid::Uuid, ClientInfo>,
client_info: &ClientInfo,
) {
fn update_client_info_map(map: &DashMap<uuid::Uuid, ClientInfo>, client_info: &ClientInfo) {
map.entry(client_info.storage_token.machine_id)
.and_modify(|e| {
if e.report_time < client_info.report_time {
@@ -78,15 +73,14 @@ impl Storage {
storage_token: stoken.clone(),
report_time,
};
Self::update_mid_to_client_info_map(&inner, &client_info);
Self::update_client_info_map(&inner, &client_info);
}
pub fn remove_client(&self, stoken: &StorageToken) {
self.0
.user_clients_map
.remove_if(&stoken.user_id, |_, set| {
Self::remove_mid_to_client_info_map(set, &stoken.machine_id, &stoken.client_url);
Self::remove_client_info_map(set, stoken);
set.is_empty()
});
}
@@ -123,4 +117,61 @@ impl Storage {
pub fn db(&self) -> &Db {
&self.0.db
}
pub async fn auto_create_user(&self, username: &str) -> anyhow::Result<UserIdInDb> {
let new_user = self.db().auto_create_user(username).await?;
tracing::info!("Auto-created user '{}' with id {}", username, new_user.id);
Ok(new_user.id)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_storage_token(
user_id: UserIdInDb,
machine_id: uuid::Uuid,
client_url: &str,
) -> StorageToken {
StorageToken {
token: format!("token-{machine_id}"),
client_url: client_url.parse().unwrap(),
machine_id,
user_id,
}
}
#[tokio::test]
async fn machine_id_is_scoped_within_each_user() {
let storage = Storage::new(Db::memory_db().await);
let machine_id = uuid::Uuid::new_v4();
let user1_token = make_storage_token(1, machine_id, "tcp://127.0.0.1:1001");
let user2_token = make_storage_token(2, machine_id, "tcp://127.0.0.1:1002");
storage.update_client(user1_token.clone(), 10);
storage.update_client(user2_token.clone(), 20);
assert_eq!(
storage.get_client_url_by_machine_id(1, &machine_id),
Some(user1_token.client_url.clone())
);
assert_eq!(
storage.get_client_url_by_machine_id(2, &machine_id),
Some(user2_token.client_url.clone())
);
storage.remove_client(&user1_token);
assert_eq!(storage.get_client_url_by_machine_id(1, &machine_id), None);
assert_eq!(
storage.get_client_url_by_machine_id(2, &machine_id),
Some(user2_token.client_url.clone())
);
storage.remove_client(&user2_token);
assert_eq!(storage.get_client_url_by_machine_id(2, &machine_id), None);
}
}
@@ -1,6 +1,9 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0
use easytier::{launcher::NetworkConfig, rpc_service::remote_client::PersistentConfig};
use easytier::{
common::config::ConfigSource, launcher::NetworkConfig,
rpc_service::remote_client::PersistentConfig,
};
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
@@ -12,10 +15,12 @@ pub struct Model {
pub user_id: i32,
#[sea_orm(column_type = "Text")]
pub device_id: String,
#[sea_orm(column_type = "Text", unique)]
#[sea_orm(column_type = "Text")]
pub network_instance_id: String,
#[sea_orm(column_type = "Text")]
pub network_config: String,
#[sea_orm(column_type = "Text")]
pub source: String,
pub disabled: bool,
pub create_time: DateTimeWithTimeZone,
pub update_time: DateTimeWithTimeZone,
@@ -48,4 +53,7 @@ impl PersistentConfig<DbErr> for Model {
fn get_network_config(&self) -> Result<NetworkConfig, DbErr> {
serde_json::from_str(&self.network_config).map_err(|e| DbErr::Json(e.to_string()))
}
fn get_network_config_source(&self) -> ConfigSource {
self.source.parse().unwrap_or(ConfigSource::User)
}
}
+199 -21
View File
@@ -3,16 +3,17 @@
pub mod entity;
use easytier::{
common::config::ConfigSource,
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,
QueryFilter as _, SqlxSqliteConnector, TransactionTrait as _,
ColumnTrait as _, DatabaseConnection, DbErr, EntityTrait, QueryFilter as _, Set,
SqlxSqliteConnector, TransactionTrait as _, prelude::Expr, sea_query::OnConflict,
};
use sea_orm_migration::MigratorTrait as _;
use sqlx::{migrate::MigrateDatabase as _, types::chrono, Sqlite, SqlitePool};
use sqlx::{Sqlite, SqlitePool, migrate::MigrateDatabase as _, types::chrono};
use uuid::Uuid;
use crate::migrator;
@@ -82,6 +83,57 @@ impl Db {
Ok(user.map(|u| u.id))
}
/// `password_hash` must be pre-hashed by the caller.
/// Creates user + joins "users" group in one transaction. Returns the created user model.
pub async fn create_user_and_join_users_group(
&self,
username: &str,
password_hash: String,
) -> Result<entity::users::Model, DbErr> {
use entity::{groups, users, users_groups};
let txn = self.orm_db().begin().await?;
let user_active = users::ActiveModel {
username: Set(username.to_string()),
password: Set(password_hash),
..Default::default()
};
let insert_result = users::Entity::insert(user_active).exec(&txn).await?;
let new_user = users::Entity::find_by_id(insert_result.last_insert_id)
.one(&txn)
.await?
.ok_or_else(|| DbErr::Custom("Failed to find newly created user".to_string()))?;
let users_group = groups::Entity::find()
.filter(groups::Column::Name.eq("users"))
.one(&txn)
.await?
.ok_or_else(|| DbErr::Custom("Users group not found".to_string()))?;
let ug_active = users_groups::ActiveModel {
user_id: Set(new_user.id),
group_id: Set(users_group.id),
..Default::default()
};
users_groups::Entity::insert(ug_active).exec(&txn).await?;
txn.commit().await?;
Ok(new_user)
}
pub async fn auto_create_user(&self, username: &str) -> Result<entity::users::Model, DbErr> {
let random_password = uuid::Uuid::new_v4().to_string();
let hashed_password =
tokio::task::spawn_blocking(move || password_auth::generate_hash(&random_password))
.await
.map_err(|e| DbErr::Custom(format!("Failed to hash password: {}", e)))?;
self.create_user_and_join_users_group(username, hashed_password)
.await
}
// TODO: currently we don't have a token system, so we just use the user name as token
pub async fn get_user_id_by_token<T: ToString>(
&self,
@@ -98,18 +150,24 @@ impl Storage<(UserIdInDb, Uuid), user_running_network_configs::Model, DbErr> for
(user_id, device_id): (UserIdInDb, Uuid),
network_inst_id: Uuid,
network_config: NetworkConfig,
source: ConfigSource,
) -> 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 on_conflict = OnConflict::columns([
urnc::Column::UserId,
urnc::Column::DeviceId,
urnc::Column::NetworkInstanceId,
])
.update_columns([
urnc::Column::NetworkConfig,
urnc::Column::Source,
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()),
@@ -117,6 +175,7 @@ impl Storage<(UserIdInDb, Uuid), user_running_network_configs::Model, DbErr> for
network_config: sea_orm::Set(
serde_json::to_string(&network_config).map_err(|e| DbErr::Json(e.to_string()))?,
),
source: sea_orm::Set(source.as_str().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()),
@@ -133,13 +192,14 @@ impl Storage<(UserIdInDb, Uuid), user_running_network_configs::Model, DbErr> for
async fn delete_network_configs(
&self,
(user_id, _): (UserIdInDb, Uuid),
(user_id, device_id): (UserIdInDb, Uuid),
network_inst_ids: &[Uuid],
) -> Result<(), DbErr> {
use entity::user_running_network_configs as urnc;
urnc::Entity::delete_many()
.filter(urnc::Column::UserId.eq(user_id))
.filter(urnc::Column::DeviceId.eq(device_id.to_string()))
.filter(
urnc::Column::NetworkInstanceId
.is_in(network_inst_ids.iter().map(|id| id.to_string())),
@@ -152,7 +212,7 @@ impl Storage<(UserIdInDb, Uuid), user_running_network_configs::Model, DbErr> for
async fn update_network_config_state(
&self,
(user_id, _): (UserIdInDb, Uuid),
(user_id, device_id): (UserIdInDb, Uuid),
network_inst_id: Uuid,
disabled: bool,
) -> Result<(), DbErr> {
@@ -160,6 +220,7 @@ impl Storage<(UserIdInDb, Uuid), user_running_network_configs::Model, DbErr> for
urnc::Entity::update_many()
.filter(urnc::Column::UserId.eq(user_id))
.filter(urnc::Column::DeviceId.eq(device_id.to_string()))
.filter(urnc::Column::NetworkInstanceId.eq(network_inst_id.to_string()))
.col_expr(urnc::Column::Disabled, Expr::value(disabled))
.col_expr(
@@ -220,10 +281,14 @@ impl Storage<(UserIdInDb, Uuid), user_running_network_configs::Model, DbErr> for
#[cfg(test)]
mod tests {
use easytier::{proto::api::manage::NetworkConfig, rpc_service::remote_client::Storage};
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter as _};
use easytier::{
common::config::ConfigSource,
proto::api::manage::NetworkConfig,
rpc_service::remote_client::{PersistentConfig, Storage},
};
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter as _, Set};
use crate::db::{entity::user_running_network_configs, Db, ListNetworkProps};
use crate::db::{Db, ListNetworkProps, entity::user_running_network_configs};
#[tokio::test]
async fn test_user_network_config_management() {
@@ -237,9 +302,14 @@ mod tests {
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)
.await
.unwrap();
db.insert_or_update_user_network_config(
(user_id, device_id),
inst_id,
network_config,
ConfigSource::User,
)
.await
.unwrap();
let result = user_running_network_configs::Entity::find()
.filter(user_running_network_configs::Column::UserId.eq(user_id))
@@ -249,6 +319,7 @@ mod tests {
.unwrap();
println!("{:?}", result);
assert_eq!(result.network_config, network_config_json);
assert_eq!(result.get_network_config_source(), ConfigSource::User);
// overwrite the config
let network_config = NetworkConfig {
@@ -256,9 +327,14 @@ mod tests {
..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();
db.insert_or_update_user_network_config(
(user_id, device_id),
inst_id,
network_config,
ConfigSource::Webhook,
)
.await
.unwrap();
let result2 = user_running_network_configs::Entity::find()
.filter(user_running_network_configs::Column::UserId.eq(user_id))
@@ -268,6 +344,11 @@ mod tests {
.unwrap();
println!("device: {}, {:?}", device_id, result2);
assert_eq!(result2.network_config, network_config_json);
assert_eq!(result2.get_network_config_source(), ConfigSource::Webhook);
assert_eq!(
result2.get_runtime_network_config_source(),
ConfigSource::Webhook
);
assert_eq!(result.create_time, result2.create_time);
assert_ne!(result.update_time, result2.update_time);
@@ -290,4 +371,101 @@ mod tests {
.unwrap();
assert!(result3.is_none());
}
#[tokio::test]
async fn test_legacy_network_config_defaults_to_user_runtime_source() {
let db = Db::memory_db().await;
let user_id = 1;
let inst_id = uuid::Uuid::new_v4();
let device_id = uuid::Uuid::new_v4();
user_running_network_configs::ActiveModel {
user_id: Set(user_id),
device_id: Set(device_id.to_string()),
network_instance_id: Set(inst_id.to_string()),
network_config: Set(serde_json::to_string(&NetworkConfig {
network_name: Some("legacy".to_string()),
..Default::default()
})
.unwrap()),
source: Set("legacy".to_string()),
disabled: Set(false),
create_time: Set(sqlx::types::chrono::Local::now().fixed_offset()),
update_time: Set(sqlx::types::chrono::Local::now().fixed_offset()),
..Default::default()
}
.insert(db.orm_db())
.await
.unwrap();
let result = user_running_network_configs::Entity::find()
.filter(user_running_network_configs::Column::UserId.eq(user_id))
.one(db.orm_db())
.await
.unwrap()
.unwrap();
assert_eq!(result.get_network_config_source(), ConfigSource::User);
assert_eq!(
result.get_runtime_network_config_source(),
ConfigSource::User
);
}
#[tokio::test]
async fn test_user_network_config_same_instance_id_is_scoped_by_device() {
let db = Db::memory_db().await;
let user_id = db.auto_create_user("user-1").await.unwrap().id;
let device1 = uuid::Uuid::new_v4();
let device2 = uuid::Uuid::new_v4();
let inst_id = uuid::Uuid::new_v4();
db.insert_or_update_user_network_config(
(user_id, device1),
inst_id,
NetworkConfig {
network_name: Some("cfg-1".to_string()),
..Default::default()
},
ConfigSource::User,
)
.await
.unwrap();
db.insert_or_update_user_network_config(
(user_id, device2),
inst_id,
NetworkConfig {
network_name: Some("cfg-2".to_string()),
..Default::default()
},
ConfigSource::User,
)
.await
.unwrap();
let first = db
.get_network_config((user_id, device1), &inst_id.to_string())
.await
.unwrap()
.unwrap();
let second = db
.get_network_config((user_id, device2), &inst_id.to_string())
.await
.unwrap()
.unwrap();
assert_eq!(first.user_id, user_id);
assert_eq!(first.device_id, device1.to_string());
assert_eq!(second.user_id, user_id);
assert_eq!(second.device_id, device2.to_string());
let device1_configs = db
.list_network_configs((user_id, device1), ListNetworkProps::All)
.await
.unwrap();
let device2_configs = db
.list_network_configs((user_id, device2), ListNetworkProps::All)
.await
.unwrap();
assert_eq!(device1_configs.len(), 1);
assert_eq!(device2_configs.len(), 1);
}
}
+153 -27
View File
@@ -3,28 +3,32 @@
#[macro_use]
extern crate rust_i18n;
use std::net::IpAddr;
use std::sync::Arc;
use clap::Parser;
use easytier::tunnel::websocket::WsTunnelListener;
use easytier::{
common::{
config::{ConsoleLoggerConfig, FileLoggerConfig, LoggingConfigLoader},
constants::EASYTIER_VERSION,
error::Error,
log,
network::{local_ipv4, local_ipv6},
},
tunnel::{
tcp::TcpTunnelListener, udp::UdpTunnelListener, websocket::WSTunnelListener, TunnelListener,
},
utils::{init_logger, setup_panic_handler},
tunnel::{TunnelListener, tcp::TcpTunnelListener, udp::UdpTunnelListener},
utils::panic::setup_panic_handler,
};
use easytier::tunnel::IpScheme;
use easytier::utils::BoxExt;
use mimalloc::MiMalloc;
mod client_manager;
mod db;
mod migrator;
mod restful;
mod webhook;
#[cfg(feature = "embed")]
mod web;
@@ -82,6 +86,13 @@ struct Cli {
)]
api_server_port: u16,
#[arg(
long,
default_value = "0.0.0.0",
help = t!("cli.api_server_addr").to_string(),
)]
api_server_addr: IpAddr,
#[arg(
long,
help = t!("cli.geoip_db").to_string(),
@@ -96,6 +107,14 @@ struct Cli {
)]
web_server_port: Option<u16>,
#[cfg(feature = "embed")]
#[arg(
long,
default_value = "0.0.0.0",
help = t!("cli.web_server_addr").to_string(),
)]
web_server_addr: IpAddr,
#[cfg(feature = "embed")]
#[arg(
long,
@@ -110,6 +129,51 @@ struct Cli {
help = t!("cli.api_host").to_string()
)]
api_host: Option<url::Url>,
#[command(flatten)]
feature_flags: FeatureFlags,
#[command(flatten)]
oidc: restful::oidc::OidcOptions,
#[command(flatten)]
webhook: WebhookOptions,
}
#[derive(Debug, Clone, Default, clap::Args)]
pub struct WebhookOptions {
/// Base URL of the webhook endpoint for token validation and event delivery.
/// When set, incoming tokens are validated via this webhook before local fallback.
#[arg(long)]
pub webhook_url: Option<String>,
/// Shared secret used to authenticate outbound webhook calls.
#[arg(long)]
pub webhook_secret: Option<String>,
/// Token for X-Internal-Auth header. When set, API requests with this header
/// bypass session authentication.
#[arg(long)]
pub internal_auth_token: Option<String>,
/// Stable identifier for this easytier-web instance when routing webhook callbacks.
#[arg(long)]
pub web_instance_id: Option<String>,
/// Reachable base URL for this easytier-web instance's internal REST API.
#[arg(long)]
pub web_instance_api_base_url: Option<String>,
}
#[derive(Debug, Clone, Default, clap::Args)]
pub struct FeatureFlags {
/// Whether user registration via the web UI is disabled.
#[arg(long, default_value = "false", help = t!("cli.disable_registration").to_string())]
pub disable_registration: bool,
/// Whether to auto-create users when they connect via heartbeat with an unknown token.
#[arg(long, default_value = "false", help = t!("cli.allow_auto_create_user").to_string())]
pub allow_auto_create_user: bool,
}
impl LoggingConfigLoader for &Cli {
@@ -130,14 +194,12 @@ impl LoggingConfigLoader for &Cli {
}
}
pub fn get_listener_by_url(l: &url::Url) -> Result<Box<dyn TunnelListener>, Error> {
Ok(match l.scheme() {
"tcp" => Box::new(TcpTunnelListener::new(l.clone())),
"udp" => Box::new(UdpTunnelListener::new(l.clone())),
"ws" => Box::new(WSTunnelListener::new(l.clone())),
_ => {
return Err(Error::InvalidUrl(l.to_string()));
}
pub fn get_listener_by_url(scheme: IpScheme, l: &url::Url) -> Option<Box<dyn TunnelListener>> {
Some(match scheme {
IpScheme::Tcp => TcpTunnelListener::new(l.clone()).boxed(),
IpScheme::Udp => UdpTunnelListener::new(l.clone()).boxed(),
IpScheme::Ws => WsTunnelListener::new(l.clone()).boxed(),
_ => return None,
})
}
@@ -151,15 +213,23 @@ async fn get_dual_stack_listener(
),
Error,
> {
let is_protocol_support_dual_stack =
protocol.trim().to_lowercase() == "tcp" || protocol.trim().to_lowercase() == "udp";
let v6_listener = if is_protocol_support_dual_stack && local_ipv6().await.is_ok() {
get_listener_by_url(&format!("{}://[::0]:{}", protocol, port).parse().unwrap()).ok()
} else {
None
};
let scheme = protocol
.parse()
.map_err(|_| Error::InvalidUrl(protocol.to_string()))?;
let v6_listener =
if local_ipv6().await.is_ok() && matches!(scheme, IpScheme::Tcp | IpScheme::Udp) {
get_listener_by_url(
scheme,
&format!("{protocol}://[::]:{port}").parse().unwrap(),
)
} else {
None
};
let v4_listener = if local_ipv4().await.is_ok() {
get_listener_by_url(&format!("{}://0.0.0.0:{}", protocol, port).parse().unwrap()).ok()
get_listener_by_url(
scheme,
&format!("{protocol}://0.0.0.0:{port}").parse().unwrap(),
)
} else {
None
};
@@ -173,11 +243,50 @@ async fn main() {
setup_panic_handler();
let cli = Cli::parse();
init_logger(&cli, false).unwrap();
log::init(&cli, false).unwrap();
// Validate OIDC configuration: check split-deploy specific requirements
// Basic OIDC parameter validation is handled in OidcConfig::from_params
if cli.oidc.any_param_provided() {
let is_split_deploy = {
#[cfg(feature = "embed")]
{
let embed_split_by_port = cli.web_server_port.is_some()
&& cli.web_server_port != Some(cli.api_server_port);
cli.no_web || embed_split_by_port
}
#[cfg(not(feature = "embed"))]
{
true
}
};
if is_split_deploy && cli.oidc.oidc_frontend_base_url.is_none() {
eprintln!("Error: --oidc-frontend-base-url is required in split-deploy mode");
eprintln!(
"When frontend and API are deployed separately, you must specify the frontend URL"
);
eprintln!("Example: --oidc-frontend-base-url http://your-frontend-domain.com");
std::process::exit(1);
}
}
// let db = db::Db::new(":memory:").await.unwrap();
let db = db::Db::new(cli.db).await.unwrap();
let mut mgr = client_manager::ClientManager::new(db.clone(), cli.geoip_db);
let feature_flags = Arc::new(cli.feature_flags);
let webhook_config = Arc::new(webhook::WebhookConfig::new(
cli.webhook.webhook_url,
cli.webhook.webhook_secret,
cli.webhook.internal_auth_token,
cli.webhook.web_instance_id,
cli.webhook.web_instance_api_base_url,
));
let mut mgr = client_manager::ClientManager::new(
db.clone(),
cli.geoip_db,
feature_flags.clone(),
webhook_config.clone(),
);
let (v6_listener, v4_listener) =
get_dual_stack_listener(&cli.config_server_protocol, cli.config_server_port)
.await
@@ -199,7 +308,10 @@ async fn main() {
(None, None)
} else {
let web_router = web::build_router(cli.api_host.clone());
if cli.web_server_port.is_none() || cli.web_server_port == Some(cli.api_server_port) {
if cli.web_server_port.is_none()
|| (cli.web_server_port == Some(cli.api_server_port)
&& cli.web_server_addr == cli.api_server_addr)
{
(Some(web_router), None)
} else {
(None, Some(web_router))
@@ -208,11 +320,27 @@ async fn main() {
#[cfg(not(feature = "embed"))]
let web_router_restful = None;
let oidc_config = if cli.oidc.oidc_issuer_url.is_some() {
match restful::oidc::OidcConfig::from_params(cli.oidc).await {
Ok(config) => config,
Err(e) => {
eprintln!("Failed to initialize OIDC: {:?}", e);
eprintln!("Please check your OIDC configuration (issuer URL, client ID, etc.)");
std::process::exit(1);
}
}
} else {
restful::oidc::OidcConfig::disabled()
};
let _restful_server_tasks = restful::RestfulServer::new(
format!("0.0.0.0:{}", cli.api_server_port).parse().unwrap(),
std::net::SocketAddr::new(cli.api_server_addr, cli.api_server_port),
mgr.clone(),
db,
web_router_restful,
feature_flags,
oidc_config,
webhook_config,
)
.await
.unwrap()
@@ -224,9 +352,7 @@ async fn main() {
let _web_server_task = if let Some(web_router) = web_router_static {
Some(
web::WebServer::new(
format!("0.0.0.0:{}", cli.web_server_port.unwrap_or(0))
.parse()
.unwrap(),
std::net::SocketAddr::new(cli.web_server_addr, cli.web_server_port.unwrap_or(0)),
web_router,
)
.await
@@ -0,0 +1,120 @@
use sea_orm_migration::prelude::*;
pub struct Migration;
impl MigrationName for Migration {
fn name(&self) -> &str {
"m20260403_000002_scope_network_config_unique"
}
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
db.execute_unprepared(
r#"
CREATE TABLE user_running_network_configs_new (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
user_id INTEGER NOT NULL,
device_id TEXT NOT NULL,
network_instance_id TEXT NOT NULL,
network_config TEXT NOT NULL,
disabled BOOLEAN NOT NULL DEFAULT FALSE,
create_time TEXT NOT NULL,
update_time TEXT NOT NULL,
CONSTRAINT fk_user_running_network_configs_user_id_to_users_id
FOREIGN KEY (user_id) REFERENCES users(id)
ON DELETE CASCADE
ON UPDATE CASCADE
);
INSERT INTO user_running_network_configs_new (
id,
user_id,
device_id,
network_instance_id,
network_config,
disabled,
create_time,
update_time
)
SELECT
id,
user_id,
device_id,
network_instance_id,
network_config,
disabled,
create_time,
update_time
FROM user_running_network_configs;
DROP TABLE user_running_network_configs;
ALTER TABLE user_running_network_configs_new RENAME TO user_running_network_configs;
CREATE INDEX idx_user_running_network_configs_user_id
ON user_running_network_configs(user_id);
CREATE UNIQUE INDEX idx_user_running_network_configs_scope_inst
ON user_running_network_configs(user_id, device_id, network_instance_id);
"#,
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
db.execute_unprepared(
r#"
CREATE TABLE user_running_network_configs_old (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
user_id INTEGER NOT NULL,
device_id TEXT NOT NULL,
network_instance_id TEXT NOT NULL UNIQUE,
network_config TEXT NOT NULL,
disabled BOOLEAN NOT NULL DEFAULT FALSE,
create_time TEXT NOT NULL,
update_time TEXT NOT NULL,
CONSTRAINT fk_user_running_network_configs_user_id_to_users_id
FOREIGN KEY (user_id) REFERENCES users(id)
ON DELETE CASCADE
ON UPDATE CASCADE
);
INSERT INTO user_running_network_configs_old (
id,
user_id,
device_id,
network_instance_id,
network_config,
disabled,
create_time,
update_time
)
SELECT
id,
user_id,
device_id,
network_instance_id,
network_config,
disabled,
create_time,
update_time
FROM user_running_network_configs;
DROP TABLE user_running_network_configs;
ALTER TABLE user_running_network_configs_old RENAME TO user_running_network_configs;
CREATE INDEX idx_user_running_network_configs_user_id
ON user_running_network_configs(user_id);
"#,
)
.await?;
Ok(())
}
}
@@ -0,0 +1,125 @@
use sea_orm_migration::prelude::*;
pub struct Migration;
impl MigrationName for Migration {
fn name(&self) -> &str {
"m20260421_000003_add_network_config_source"
}
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
db.execute_unprepared(
r#"
CREATE TABLE user_running_network_configs_new (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
user_id INTEGER NOT NULL,
device_id TEXT NOT NULL,
network_instance_id TEXT NOT NULL,
network_config TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'user',
disabled BOOLEAN NOT NULL DEFAULT FALSE,
create_time TEXT NOT NULL,
update_time TEXT NOT NULL,
CONSTRAINT fk_user_running_network_configs_user_id_to_users_id
FOREIGN KEY (user_id) REFERENCES users(id)
ON DELETE CASCADE
ON UPDATE CASCADE
);
INSERT INTO user_running_network_configs_new (
id,
user_id,
device_id,
network_instance_id,
network_config,
source,
disabled,
create_time,
update_time
)
SELECT
id,
user_id,
device_id,
network_instance_id,
network_config,
'legacy',
disabled,
create_time,
update_time
FROM user_running_network_configs;
DROP TABLE user_running_network_configs;
ALTER TABLE user_running_network_configs_new RENAME TO user_running_network_configs;
CREATE INDEX idx_user_running_network_configs_user_id
ON user_running_network_configs(user_id);
CREATE UNIQUE INDEX idx_user_running_network_configs_scope_inst
ON user_running_network_configs(user_id, device_id, network_instance_id);
"#,
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
db.execute_unprepared(
r#"
CREATE TABLE user_running_network_configs_old (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
user_id INTEGER NOT NULL,
device_id TEXT NOT NULL,
network_instance_id TEXT NOT NULL,
network_config TEXT NOT NULL,
disabled BOOLEAN NOT NULL DEFAULT FALSE,
create_time TEXT NOT NULL,
update_time TEXT NOT NULL,
CONSTRAINT fk_user_running_network_configs_user_id_to_users_id
FOREIGN KEY (user_id) REFERENCES users(id)
ON DELETE CASCADE
ON UPDATE CASCADE
);
INSERT INTO user_running_network_configs_old (
id,
user_id,
device_id,
network_instance_id,
network_config,
disabled,
create_time,
update_time
)
SELECT
id,
user_id,
device_id,
network_instance_id,
network_config,
disabled,
create_time,
update_time
FROM user_running_network_configs;
DROP TABLE user_running_network_configs;
ALTER TABLE user_running_network_configs_old RENAME TO user_running_network_configs;
CREATE INDEX idx_user_running_network_configs_user_id
ON user_running_network_configs(user_id);
CREATE UNIQUE INDEX idx_user_running_network_configs_scope_inst
ON user_running_network_configs(user_id, device_id, network_instance_id);
"#,
)
.await?;
Ok(())
}
}
+7 -1
View File
@@ -1,12 +1,18 @@
use sea_orm_migration::prelude::*;
mod m20241029_000001_init;
mod m20260403_000002_scope_network_config_unique;
mod m20260421_000003_add_network_config_source;
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![Box::new(m20241029_000001_init::Migration)]
vec![
Box::new(m20241029_000001_init::Migration),
Box::new(m20260403_000002_scope_network_config_unique::Migration),
Box::new(m20260421_000003_add_network_config_source::Migration),
]
}
}
+26 -11
View File
@@ -1,7 +1,7 @@
use axum::{
Router,
http::StatusCode,
routing::{get, post, put},
Router,
};
use axum_login::login_required;
use axum_messages::Message;
@@ -9,9 +9,13 @@ use serde::{Deserialize, Serialize};
use crate::restful::users::Backend;
use std::sync::Arc;
use crate::FeatureFlags;
use super::{
users::{AuthSession, Credentials},
AppStateInner,
users::{AuthSession, Credentials},
};
#[derive(Debug, Deserialize, Serialize)]
@@ -40,7 +44,7 @@ mod put {
use axum_login::AuthUser;
use easytier::proto::common::Void;
use crate::restful::{other_error, users::ChangePassword, HttpHandleError};
use crate::restful::{HttpHandleError, other_error, users::ChangePassword};
use super::*;
@@ -67,14 +71,14 @@ mod put {
}
mod post {
use axum::Json;
use axum::{Json, extract::Extension};
use easytier::proto::common::Void;
use crate::restful::{
captcha::extension::{axum_tower_sessions::CaptchaAxumTowerSessionStaticExt, CaptchaUtil},
HttpHandleError,
captcha::extension::{CaptchaUtil, axum_tower_sessions::CaptchaAxumTowerSessionStaticExt},
other_error,
users::RegisterNewUser,
HttpHandleError,
};
use super::*;
@@ -95,7 +99,7 @@ mod post {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json::from(other_error(format!("{:?}", e))),
))
));
}
};
@@ -110,10 +114,20 @@ mod post {
}
pub async fn register(
Extension(feature_flags): Extension<Arc<FeatureFlags>>,
auth_session: AuthSession,
captcha_session: tower_sessions::Session,
Json(req): Json<RegisterNewUser>,
) -> Result<Json<Void>, HttpHandleError> {
// Check if registration is disabled
if feature_flags.disable_registration {
tracing::warn!("Registration attempt blocked: registration is disabled");
return Err((
StatusCode::FORBIDDEN,
other_error("Registration is disabled").into(),
));
}
// 调用CaptchaUtil的静态方法验证验证码是否正确
if !CaptchaUtil::ver(&req.captcha, &captcha_session).await {
return Err((
@@ -136,14 +150,15 @@ mod post {
mod get {
use crate::restful::{
HttpHandleError,
captcha::{
builder::spec::SpecCaptcha,
extension::{axum_tower_sessions::CaptchaAxumTowerSessionExt as _, CaptchaUtil},
NewCaptcha as _,
builder::spec::SpecCaptcha,
extension::{CaptchaUtil, axum_tower_sessions::CaptchaAxumTowerSessionExt as _},
},
other_error, HttpHandleError,
other_error,
};
use axum::{response::Response, Json};
use axum::{Json, response::Response};
use easytier::proto::common::Void;
use tower_sessions::Session;
@@ -2,8 +2,8 @@ use super::super::base::randoms::Randoms;
use super::super::utils::color::Color;
use super::super::utils::font;
use base64::prelude::BASE64_STANDARD;
use base64::Engine;
use base64::prelude::BASE64_STANDARD;
use rusttype::Font;
use std::fmt::Debug;
@@ -9,14 +9,14 @@ use super::super::{CaptchaFont, NewCaptcha};
use image::{ImageBuffer, Rgba};
use imageproc::drawing;
use rand::{rngs::ThreadRng, Rng};
use rand::{Rng, rngs::ThreadRng};
use rusttype::{Font, Scale};
use std::io::{Cursor, Write};
use std::sync::Arc;
mod color {
use image::Rgba;
use rand::{rngs::ThreadRng, Rng};
use rand::{Rng, rngs::ThreadRng};
pub fn gen_background_color(rng: &mut ThreadRng) -> Rgba<u8> {
let red = rng.gen_range(200..=255);
let green = rng.gen_range(200..=255);
@@ -133,7 +133,7 @@ impl<'a, 'b> CaptchaBuilder<'a, 'b> {
fn draw_line(&self, image: &mut ImageBuffer<Rgba<u8>, Vec<u8>>, rng: &mut ThreadRng) {
let line_color = color::gen_line_color(rng);
let is_h = rng.gen();
let is_h = rng.r#gen();
let (start, end) = if is_h {
let xa = rng.gen_range(0.0..(self.width as f32) / 2.0);
let ya = rng.gen_range(0.0..(self.height as f32));
+116 -32
View File
@@ -1,32 +1,39 @@
mod auth;
pub(crate) mod captcha;
mod network;
pub(crate) mod oidc;
mod rpc;
mod users;
use std::{net::SocketAddr, sync::Arc};
use axum::http::StatusCode;
use axum::routing::post;
use axum::{extract::State, routing::get, Json, Router};
use axum::extract::Path;
use axum::http::{Request, StatusCode, header};
use axum::middleware::{self as axum_mw, Next};
use axum::response::Response;
use axum::routing::{delete, post};
use axum::{Extension, Json, Router, extract::State, routing::get};
use axum_login::tower_sessions::{ExpiredDeletion, SessionManagerLayer};
use axum_login::{login_required, AuthManagerLayerBuilder, AuthUser, AuthzBackend};
use axum_login::{AuthManagerLayerBuilder, AuthUser, AuthzBackend, login_required};
use axum_messages::MessagesManagerLayer;
use easytier::common::config::{ConfigLoader, TomlConfigLoader};
use easytier::common::scoped_task::ScopedTask;
use easytier::launcher::NetworkConfig;
use easytier::proto::rpc_types;
use network::NetworkApi;
use sea_orm::DbErr;
use tokio::net::TcpListener;
use tower_sessions::cookie::time::Duration;
use tower_sessions::cookie::Key;
use tokio_util::task::AbortOnDropHandle;
use tower_sessions::Expiry;
use tower_sessions::cookie::time::Duration;
use tower_sessions::cookie::{Key, SameSite};
use tower_sessions_sqlx_store::SqliteStore;
use users::{AuthSession, Backend};
use crate::client_manager::storage::StorageToken;
use crate::FeatureFlags;
use crate::client_manager::ClientManager;
use crate::db::Db;
use crate::client_manager::storage::StorageToken;
use crate::db::{Db, UserIdInDb};
use crate::webhook::SharedWebhookConfig;
/// Embed assets for web dashboard, build frontend first
#[cfg(feature = "embed")]
@@ -37,11 +44,10 @@ struct Assets;
pub struct RestfulServer {
bind_addr: SocketAddr,
client_mgr: Arc<ClientManager>,
feature_flags: Arc<FeatureFlags>,
webhook_config: SharedWebhookConfig,
db: Db,
// serve_task: Option<ScopedTask<()>>,
// delete_task: Option<ScopedTask<tower_sessions::session_store::Result<()>>>,
// network_api: NetworkApi<WebClientManager>,
oidc_config: oidc::OidcConfig,
web_router: Option<Router>,
}
@@ -104,18 +110,19 @@ impl RestfulServer {
client_mgr: Arc<ClientManager>,
db: Db,
web_router: Option<Router>,
feature_flags: Arc<FeatureFlags>,
oidc_config: oidc::OidcConfig,
webhook_config: SharedWebhookConfig,
) -> anyhow::Result<Self> {
assert!(client_mgr.is_running());
// let network_api = NetworkApi::new();
Ok(RestfulServer {
bind_addr,
client_mgr,
feature_flags,
webhook_config,
db,
// serve_task: None,
// delete_task: None,
// network_api,
oidc_config,
web_router,
})
}
@@ -192,8 +199,8 @@ impl RestfulServer {
mut self,
) -> Result<
(
ScopedTask<()>,
ScopedTask<tower_sessions::session_store::Result<()>>,
AbortOnDropHandle<()>,
AbortOnDropHandle<tower_sessions::session_store::Result<()>>,
),
anyhow::Error,
> {
@@ -206,19 +213,18 @@ impl RestfulServer {
let session_store = SqliteStore::new(self.db.inner());
session_store.migrate().await?;
let delete_task: ScopedTask<tower_sessions::session_store::Result<()>> =
tokio::task::spawn(
session_store
.clone()
.continuously_delete_expired(tokio::time::Duration::from_secs(60)),
)
.into();
let delete_task = AbortOnDropHandle::new(tokio::task::spawn(
session_store
.clone()
.continuously_delete_expired(tokio::time::Duration::from_secs(60)),
));
// Generate a cryptographic key to sign the session cookie.
let key = Key::generate();
let session_layer = SessionManagerLayer::new(session_store)
.with_secure(false)
.with_same_site(SameSite::Lax)
.with_expiry(Expiry::OnInactivity(Duration::days(1)))
.with_signed(key);
@@ -235,23 +241,54 @@ impl RestfulServer {
.zstd(true)
.quality(tower_http::compression::CompressionLevel::Default);
let app = Router::new()
// Token-authenticated management routes that bypass session auth.
let internal_app = if self.webhook_config.has_internal_auth() {
let internal_token = self.webhook_config.internal_auth_token.clone().unwrap();
let internal_routes = Router::new()
.route(
"/api/internal/sessions",
get(Self::handle_list_all_sessions_internal),
)
.route(
"/api/internal/users/:user-id/sessions/:machine-id",
delete(Self::handle_disconnect_session_internal),
)
.merge(NetworkApi::build_route_internal())
.merge(rpc::router_internal())
.with_state(self.client_mgr.clone())
.layer(axum_mw::from_fn(move |req, next| {
let token = internal_token.clone();
internal_auth_middleware(token, req, next)
}));
Some(internal_routes)
} else {
None
};
let mut app = Router::new()
.route("/api/v1/summary", get(Self::handle_get_summary))
.route("/api/v1/sessions", get(Self::handle_list_all_sessions))
.merge(NetworkApi::build_route())
.merge(rpc::router())
.route_layer(login_required!(Backend))
.merge(auth::router())
.merge(auth::router().layer(Extension(self.feature_flags.clone())))
.merge(oidc::router())
.with_state(self.client_mgr.clone())
.route(
"/api/v1/generate-config",
post(Self::handle_generate_config),
)
.route("/api/v1/parse-config", post(Self::handle_parse_config))
.layer(Extension(self.oidc_config.clone()))
.layer(MessagesManagerLayer)
.layer(auth_layer)
.layer(tower_http::cors::CorsLayer::very_permissive())
.layer(compression_layer);
if let Some(internal_routes) = internal_app {
app = app.merge(internal_routes);
}
#[cfg(feature = "embed")]
let app = if let Some(web_router) = self.web_router.take() {
app.merge(web_router)
@@ -259,11 +296,58 @@ impl RestfulServer {
app
};
let serve_task: ScopedTask<()> = tokio::spawn(async move {
let serve_task = AbortOnDropHandle::new(tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
})
.into();
}));
Ok((serve_task, delete_task))
}
/// Session listing endpoint for token-authenticated management clients.
async fn handle_list_all_sessions_internal(
State(client_mgr): AppState,
) -> Result<Json<ListSessionJsonResp>, HttpHandleError> {
let ret = client_mgr.list_sessions().await;
Ok(ListSessionJsonResp(ret).into())
}
async fn handle_disconnect_session_internal(
Path((user_id, machine_id)): Path<(UserIdInDb, uuid::Uuid)>,
State(client_mgr): AppState,
) -> Result<StatusCode, HttpHandleError> {
if client_mgr
.disconnect_session_by_machine_id(user_id, &machine_id)
.await
{
Ok(StatusCode::NO_CONTENT)
} else {
Err((
StatusCode::NOT_FOUND,
other_error("session not found").into(),
))
}
}
}
/// Middleware that validates X-Internal-Auth for token-authenticated routes.
async fn internal_auth_middleware(
expected_token: String,
req: Request<axum::body::Body>,
next: Next,
) -> Response {
let auth_header = req
.headers()
.get("X-Internal-Auth")
.and_then(|v| v.to_str().ok());
match auth_header {
Some(token) if token == expected_token => next.run(req).await,
_ => Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(header::CONTENT_TYPE, "application/json")
.body(axum::body::Body::from(
r#"{"error":"unauthorized: invalid or missing X-Internal-Auth header"}"#,
))
.unwrap(),
}
}
+66 -2
View File
@@ -1,7 +1,7 @@
use axum::extract::Path;
use axum::http::StatusCode;
use axum::routing::{delete, post};
use axum::{extract::State, routing::get, Json, Router};
use axum::{Json, Router, extract::State, routing::get};
use axum_login::AuthUser;
use easytier::launcher::NetworkConfig;
use easytier::proto::common::Void;
@@ -16,7 +16,7 @@ use crate::db::UserIdInDb;
use super::users::AuthSession;
use super::{
convert_db_error, other_error, AppState, AppStateInner, Error, HttpHandleError, RpcError,
AppState, AppStateInner, Error, HttpHandleError, RpcError, convert_db_error, other_error,
};
fn convert_rpc_error(e: RpcError) -> (StatusCode, Json<Error>) {
@@ -295,6 +295,70 @@ impl NetworkApi {
.into())
}
// --- Token-authenticated machine-scoped handlers (no AuthSession) ---
async fn handle_run_network_instance_internal(
State(client_mgr): AppState,
Path((user_id, machine_id)): Path<(UserIdInDb, uuid::Uuid)>,
Json(payload): Json<RunNetworkJsonReq>,
) -> Result<Json<Void>, HttpHandleError> {
client_mgr
.handle_run_network_instance((user_id, machine_id), payload.config, payload.save)
.await
.map_err(convert_error)?;
Ok(Void::default().into())
}
async fn handle_remove_network_instance_internal(
State(client_mgr): AppState,
Path((user_id, machine_id, inst_id)): Path<(UserIdInDb, uuid::Uuid, uuid::Uuid)>,
) -> Result<(), HttpHandleError> {
client_mgr
.handle_remove_network_instances((user_id, machine_id), vec![inst_id])
.await
.map_err(convert_error)
}
async fn handle_list_network_instance_ids_internal(
State(client_mgr): AppState,
Path((user_id, machine_id)): Path<(UserIdInDb, uuid::Uuid)>,
) -> Result<Json<ListNetworkInstanceIdsJsonResp>, HttpHandleError> {
Ok(client_mgr
.handle_list_network_instance_ids((user_id, machine_id))
.await
.map_err(convert_error)?
.into())
}
async fn handle_collect_network_info_internal(
State(client_mgr): AppState,
Path((user_id, machine_id)): Path<(UserIdInDb, uuid::Uuid)>,
Json(payload): Json<CollectNetworkInfoJsonReq>,
) -> Result<Json<CollectNetworkInfoResponse>, HttpHandleError> {
Ok(client_mgr
.handle_collect_network_info((user_id, machine_id), payload.inst_ids)
.await
.map_err(convert_error)?
.into())
}
pub fn build_route_internal() -> Router<AppStateInner> {
Router::new()
.route(
"/api/internal/users/:user-id/machines/:machine-id/networks",
post(Self::handle_run_network_instance_internal)
.get(Self::handle_list_network_instance_ids_internal),
)
.route(
"/api/internal/users/:user-id/machines/:machine-id/networks/:inst-id",
delete(Self::handle_remove_network_instance_internal),
)
.route(
"/api/internal/users/:user-id/machines/:machine-id/networks/info",
get(Self::handle_collect_network_info_internal),
)
}
pub fn build_route() -> Router<AppStateInner> {
Router::new()
.route("/api/v1/machines", get(Self::handle_list_machines))
+735
View File
@@ -0,0 +1,735 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use subtle::ConstantTimeEq;
use axum::Router;
use axum::routing::get;
use openidconnect::core::{
CoreAuthDisplay, CoreAuthPrompt, CoreErrorResponseType, CoreGenderClaim, CoreJsonWebKey,
CoreJweContentEncryptionAlgorithm, CoreJwsSigningAlgorithm, CoreProviderMetadata,
CoreRevocableToken, CoreRevocationErrorResponse, CoreTokenIntrospectionResponse, CoreTokenType,
};
use openidconnect::{
Client, ClientId, ClientSecret, EmptyExtraTokenFields, EndpointMaybeSet, EndpointNotSet,
EndpointSet, IdTokenFields, IssuerUrl, RedirectUrl, StandardErrorResponse,
StandardTokenResponse,
};
use serde::{Deserialize, Serialize};
use super::AppStateInner;
const DEFAULT_OIDC_SCOPES: [&str; 2] = ["openid", "profile"];
fn normalize_oidc_scopes(scopes: &[String]) -> Vec<String> {
let mut normalized: Vec<String> = scopes
.iter()
.map(|scope| scope.trim().to_string())
.filter(|scope| !scope.is_empty())
.collect();
if normalized.is_empty() {
normalized = DEFAULT_OIDC_SCOPES
.iter()
.map(|scope| scope.to_string())
.collect();
}
if !normalized.iter().any(|scope| scope == "openid") {
normalized.insert(0, "openid".to_string());
}
normalized
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct JsonAdditionalClaims {
#[serde(flatten)]
pub claims: HashMap<String, serde_json::Value>,
}
impl openidconnect::AdditionalClaims for JsonAdditionalClaims {}
pub type AppIdTokenFields = IdTokenFields<
JsonAdditionalClaims,
EmptyExtraTokenFields,
CoreGenderClaim,
CoreJweContentEncryptionAlgorithm,
CoreJwsSigningAlgorithm,
>;
pub type AppTokenResponse = StandardTokenResponse<AppIdTokenFields, CoreTokenType>;
pub type AppClient<
HasAuthUrl = EndpointNotSet,
HasDeviceAuthUrl = EndpointNotSet,
HasIntrospectionUrl = EndpointNotSet,
HasRevocationUrl = EndpointNotSet,
HasTokenUrl = EndpointNotSet,
HasUserInfoUrl = EndpointNotSet,
> = Client<
JsonAdditionalClaims,
CoreAuthDisplay,
CoreGenderClaim,
CoreJweContentEncryptionAlgorithm,
CoreJsonWebKey,
CoreAuthPrompt,
StandardErrorResponse<CoreErrorResponseType>,
AppTokenResponse,
CoreTokenIntrospectionResponse,
CoreRevocableToken,
CoreRevocationErrorResponse,
HasAuthUrl,
HasDeviceAuthUrl,
HasIntrospectionUrl,
HasRevocationUrl,
HasTokenUrl,
HasUserInfoUrl,
>;
pub type ConfiguredAppClient = AppClient<
EndpointSet,
EndpointNotSet,
EndpointNotSet,
EndpointNotSet,
EndpointMaybeSet,
EndpointMaybeSet,
>;
/// Convert a dot-path (e.g. `realm_access.roles.0`) to a JSON Pointer (e.g. `/realm_access/roles/0`).
/// Each segment is escaped per RFC 6901: `~` → `~0`, `/` → `~1`.
fn dot_path_to_json_pointer(dot_path: &str) -> String {
let mut pointer = String::new();
for segment in dot_path.split('.') {
pointer.push('/');
for ch in segment.chars() {
match ch {
'~' => pointer.push_str("~0"),
'/' => pointer.push_str("~1"),
_ => pointer.push(ch),
}
}
}
pointer
}
/// Timing-safe string comparison via constant-time equality check.
/// Prevents timing side-channel attacks on CSRF token verification.
fn timing_safe_eq(a: &str, b: &str) -> bool {
if a.len() != b.len() {
return false;
}
a.as_bytes().ct_eq(b.as_bytes()).into()
}
#[derive(Debug, Clone, clap::Args)]
pub struct OidcOptions {
#[arg(long, help = t!("cli.oidc_issuer_url").to_string())]
pub oidc_issuer_url: Option<String>,
#[arg(long, help = t!("cli.oidc_client_id").to_string())]
pub oidc_client_id: Option<String>,
#[arg(long, env = "OIDC_CLIENT_SECRET", help = t!("cli.oidc_client_secret").to_string())]
pub oidc_client_secret: Option<String>,
#[arg(long, default_value = "preferred_username", help = t!("cli.oidc_username_claim").to_string())]
pub oidc_username_claim: String,
#[arg(
long,
value_delimiter = ',',
default_values = DEFAULT_OIDC_SCOPES,
help = t!("cli.oidc_scopes").to_string()
)]
pub oidc_scopes: Vec<String>,
#[arg(long, help = t!("cli.oidc_redirect_url").to_string())]
pub oidc_redirect_url: Option<String>,
#[arg(long, default_value = "false", help = t!("cli.oidc_disable_pkce").to_string())]
pub oidc_disable_pkce: bool,
#[arg(long, help = t!("cli.oidc_frontend_base_url").to_string())]
pub oidc_frontend_base_url: Option<String>,
}
impl OidcOptions {
pub fn any_param_provided(&self) -> bool {
self.oidc_issuer_url.is_some()
|| self.oidc_client_id.is_some()
|| self.oidc_client_secret.is_some()
|| self.oidc_redirect_url.is_some()
|| self.oidc_frontend_base_url.is_some()
|| self.oidc_username_claim != "preferred_username"
|| self.oidc_scopes != DEFAULT_OIDC_SCOPES
|| self.oidc_disable_pkce
}
}
#[derive(Clone)]
pub struct OidcConfig {
pub enabled: bool,
pub provider_metadata: Option<Arc<CoreProviderMetadata>>,
pub client_id: String,
pub client_secret: Option<String>,
pub redirect_url: Option<RedirectUrl>,
pub username_claim: String,
pub scopes: Vec<String>,
pub pkce_enabled: bool,
pub frontend_base_url: Option<String>,
pub http_client: Option<reqwest::Client>,
cached_client: Option<Arc<ConfiguredAppClient>>,
}
impl OidcConfig {
pub fn disabled() -> Self {
Self {
enabled: false,
provider_metadata: None,
client_id: String::new(),
client_secret: None,
redirect_url: None,
username_claim: "preferred_username".to_string(),
scopes: DEFAULT_OIDC_SCOPES
.iter()
.map(|scope| scope.to_string())
.collect(),
pkce_enabled: false,
frontend_base_url: None,
http_client: None,
cached_client: None,
}
}
pub async fn from_params(opts: OidcOptions) -> anyhow::Result<Self> {
let OidcOptions {
oidc_issuer_url,
oidc_client_id,
oidc_client_secret,
oidc_username_claim,
oidc_scopes,
oidc_redirect_url,
oidc_disable_pkce,
oidc_frontend_base_url,
} = opts;
if oidc_issuer_url.is_none() || oidc_client_id.is_none() || oidc_redirect_url.is_none() {
return Err(anyhow::anyhow!(
"--oidc-issuer-url, --oidc-client-id and --oidc-redirect-url are required when using OIDC authentication"
));
}
if oidc_username_claim.trim().is_empty() {
return Err(anyhow::anyhow!("--oidc-username-claim cannot be empty"));
}
let http_client = reqwest::ClientBuilder::new()
.redirect(reqwest::redirect::Policy::none())
.timeout(Duration::from_secs(30))
.build()?;
let issuer_url = oidc_issuer_url.ok_or_else(|| {
anyhow::anyhow!("--oidc-issuer-url is required when using OIDC authentication")
})?;
let provider_metadata =
CoreProviderMetadata::discover_async(IssuerUrl::new(issuer_url)?, &http_client).await?;
let client_id = oidc_client_id.ok_or_else(|| {
anyhow::anyhow!("--oidc-client-id is required when using OIDC authentication")
})?;
let redirect_url = oidc_redirect_url
.ok_or_else(|| anyhow::anyhow!("--oidc-redirect-url is required when using OIDC authentication. The redirect URL must match exactly what is registered with your Identity Provider. Example: --oidc-redirect-url http://your-domain.com:11211/api/v1/auth/oidc/callback"))?;
let provider_metadata = Arc::new(provider_metadata);
let redirect_url = RedirectUrl::new(redirect_url)?;
let client_secret = oidc_client_secret;
let cached_client = {
let c = AppClient::from_provider_metadata(
provider_metadata.as_ref().clone(),
ClientId::new(client_id.clone()),
client_secret.as_ref().map(|s| ClientSecret::new(s.clone())),
)
.set_redirect_uri(redirect_url.clone());
Arc::new(c)
};
Ok(Self {
enabled: true,
provider_metadata: Some(provider_metadata),
client_id,
client_secret,
redirect_url: Some(redirect_url),
username_claim: oidc_username_claim,
scopes: normalize_oidc_scopes(&oidc_scopes),
pkce_enabled: !oidc_disable_pkce,
frontend_base_url: oidc_frontend_base_url,
http_client: Some(http_client),
cached_client: Some(cached_client),
})
}
pub fn client(&self) -> Option<&ConfiguredAppClient> {
self.cached_client.as_deref()
}
}
pub fn router() -> Router<AppStateInner> {
Router::new()
.route("/api/v1/auth/oidc/config", get(self::route::oidc_config))
.route("/api/v1/auth/oidc/login", get(self::route::oidc_login))
.route(
"/api/v1/auth/oidc/callback",
get(self::route::oidc_callback),
)
}
mod route {
use axum::extract::Query;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Redirect, Response};
use axum::{Extension, Json};
use openidconnect::core::CoreAuthenticationFlow;
use openidconnect::{
AccessTokenHash, AuthorizationCode, CsrfToken, Nonce, OAuth2TokenResponse,
PkceCodeChallenge, PkceCodeVerifier, Scope, TokenResponse,
};
use serde::Deserialize;
use crate::restful::other_error;
use crate::restful::users::AuthSession;
use super::OidcConfig;
pub async fn oidc_config(Extension(oidc): Extension<OidcConfig>) -> Json<serde_json::Value> {
Json(serde_json::json!({ "enabled": oidc.enabled }))
}
pub async fn oidc_login(
Extension(oidc): Extension<OidcConfig>,
session: tower_sessions::Session,
) -> Response {
if !oidc.enabled {
return (
StatusCode::BAD_REQUEST,
Json(other_error("OIDC is not enabled")),
)
.into_response();
}
let client = match oidc.client() {
Some(c) => c,
None => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(other_error("OIDC client not initialized")),
)
.into_response();
}
};
let scopes = oidc.scopes.clone();
let pkce_enabled = oidc.pkce_enabled;
let (pkce_challenge, pkce_verifier) = if pkce_enabled {
let (challenge, verifier) = PkceCodeChallenge::new_random_sha256();
(Some(challenge), Some(verifier))
} else {
(None, None)
};
let mut auth_request = client.authorize_url(
CoreAuthenticationFlow::AuthorizationCode,
CsrfToken::new_random,
Nonce::new_random,
);
for scope in &scopes {
auth_request = auth_request.add_scope(Scope::new(scope.clone()));
}
if let Some(challenge) = pkce_challenge {
auth_request = auth_request.set_pkce_challenge(challenge);
}
let (auth_url, csrf_token, nonce) = auth_request.url();
if let Err(e) = session
.insert("oidc_csrf_token", csrf_token.secret().clone())
.await
{
tracing::error!("Failed to store csrf_token in session: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(other_error("Session error")),
)
.into_response();
}
if let Err(e) = session.insert("oidc_nonce", nonce.secret().clone()).await {
tracing::error!("Failed to store nonce in session: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(other_error("Session error")),
)
.into_response();
}
if let Some(verifier) = pkce_verifier
&& let Err(e) = session
.insert("oidc_pkce_verifier", verifier.secret().clone())
.await
{
tracing::error!("Failed to store pkce_verifier in session: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(other_error("Session error")),
)
.into_response();
}
if let Err(e) = session.insert("oidc_pkce_used", pkce_enabled).await {
tracing::error!("Failed to store pkce_used in session: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(other_error("Session error")),
)
.into_response();
}
Redirect::temporary(auth_url.as_str()).into_response()
}
#[derive(Deserialize)]
pub struct CallbackParams {
code: Option<String>,
state: Option<String>,
error: Option<String>,
error_description: Option<String>,
}
async fn cleanup_oidc_session(session: &tower_sessions::Session) {
let _ = session.remove::<String>("oidc_csrf_token").await;
let _ = session.remove::<String>("oidc_nonce").await;
let _ = session.remove::<String>("oidc_pkce_verifier").await;
let _ = session.remove::<bool>("oidc_pkce_used").await;
}
pub async fn oidc_callback(
Extension(oidc): Extension<OidcConfig>,
Query(params): Query<CallbackParams>,
session: tower_sessions::Session,
mut auth_session: AuthSession,
) -> Response {
if !oidc.enabled {
return (
StatusCode::BAD_REQUEST,
Json(other_error("OIDC is not enabled")),
)
.into_response();
}
if let Some(ref error) = params.error {
tracing::error!(
"OIDC provider returned error: {}, description: {:?}",
error,
params.error_description
);
return (
StatusCode::BAD_REQUEST,
Json(other_error(
"Authentication failed at the identity provider",
)),
)
.into_response();
}
let code = match params.code {
Some(ref c) => c.clone(),
None => {
return (
StatusCode::BAD_REQUEST,
Json(other_error("Missing authorization code")),
)
.into_response();
}
};
let callback_state = match params.state {
Some(ref s) => s.clone(),
None => {
return (
StatusCode::BAD_REQUEST,
Json(other_error("Missing state parameter in callback")),
)
.into_response();
}
};
let stored_csrf: String = match session.get("oidc_csrf_token").await {
Ok(Some(v)) => v,
_ => {
return (
StatusCode::BAD_REQUEST,
Json(other_error("Missing or invalid CSRF token in session")),
)
.into_response();
}
};
if !super::timing_safe_eq(&stored_csrf, &callback_state) {
return (
StatusCode::BAD_REQUEST,
Json(other_error("CSRF state mismatch")),
)
.into_response();
}
let stored_nonce: String = match session.get("oidc_nonce").await {
Ok(Some(v)) => v,
_ => {
return (
StatusCode::BAD_REQUEST,
Json(other_error("Missing nonce in session")),
)
.into_response();
}
};
let stored_pkce_verifier: Option<String> =
session.get("oidc_pkce_verifier").await.ok().flatten();
let pkce_was_used: Option<bool> = session.get("oidc_pkce_used").await.ok().flatten();
cleanup_oidc_session(&session).await;
let client = match oidc.client() {
Some(c) => c,
None => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(other_error("OIDC client not initialized")),
)
.into_response();
}
};
let http_client = match oidc.http_client.as_ref() {
Some(c) => c,
None => {
tracing::error!("HTTP client not initialized in OIDC config");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(other_error("OIDC internal error")),
)
.into_response();
}
};
let mut token_request = match client.exchange_code(AuthorizationCode::new(code)) {
Ok(req) => req,
Err(e) => {
tracing::error!("Failed to create token request: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(other_error("Failed to create token exchange request")),
)
.into_response();
}
};
if let Some(stored_pkce_verifier) = stored_pkce_verifier {
token_request =
token_request.set_pkce_verifier(PkceCodeVerifier::new(stored_pkce_verifier));
} else if pkce_was_used == Some(true) {
return (
StatusCode::BAD_REQUEST,
Json(other_error(
"PKCE was enabled but verifier is missing from session (session may have expired)",
)),
)
.into_response();
}
let token_response = match token_request.request_async(http_client).await {
Ok(resp) => resp,
Err(e) => {
tracing::error!("Failed to exchange code for token: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(other_error("Token exchange failed")),
)
.into_response();
}
};
let id_token = match token_response.id_token() {
Some(t) => t,
None => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(other_error("No ID token in response")),
)
.into_response();
}
};
let claims = match id_token.claims(&client.id_token_verifier(), &Nonce::new(stored_nonce)) {
Ok(c) => c,
Err(e) => {
tracing::error!("Failed to verify ID token: {:?}", e);
return (
StatusCode::UNAUTHORIZED,
Json(other_error("ID token verification failed")),
)
.into_response();
}
};
if let Some(expected_at_hash) = claims.access_token_hash() {
let id_token_verifier = client.id_token_verifier();
let (Ok(signing_alg), Ok(signing_key)) = (
id_token.signing_alg(),
id_token.signing_key(&id_token_verifier),
) else {
tracing::error!("Failed to get signing algorithm or key for at_hash verification");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(other_error("Failed to determine token signing algorithm")),
)
.into_response();
};
let actual_at_hash = match AccessTokenHash::from_token(
token_response.access_token(),
signing_alg,
signing_key,
) {
Ok(hash) => hash,
Err(e) => {
tracing::error!("Failed to compute access token hash: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(other_error("Failed to verify access token hash")),
)
.into_response();
}
};
if actual_at_hash != *expected_at_hash {
tracing::error!("Access token hash mismatch");
return (
StatusCode::UNAUTHORIZED,
Json(other_error("Access token hash mismatch")),
)
.into_response();
}
}
let claims_json = match serde_json::to_value(claims) {
Ok(v) => v,
Err(e) => {
tracing::error!("Failed to serialize claims: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(other_error("Failed to process ID token claims")),
)
.into_response();
}
};
let pointer = super::dot_path_to_json_pointer(&oidc.username_claim);
let username: Option<String> = claims_json
.pointer(&pointer)
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let username = match username {
Some(u) if !u.is_empty() => u,
_ => {
tracing::error!(
"Could not extract username from claim '{}' in token",
oidc.username_claim
);
return (
StatusCode::BAD_REQUEST,
Json(other_error("Could not extract username from token claims")),
)
.into_response();
}
};
let user = match auth_session
.backend
.find_or_create_oidc_user(&username)
.await
{
Ok(u) => u,
Err(e) => {
tracing::error!("Failed to find or create OIDC user '{}': {:?}", username, e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(other_error("Failed to provision user account")),
)
.into_response();
}
};
if let Err(e) = auth_session.login(&user).await {
tracing::error!("Failed to login user via OIDC: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(other_error("Failed to establish session")),
)
.into_response();
}
if let Err(e) = session.cycle_id().await {
tracing::error!("Failed to cycle session ID after OIDC login: {:?}", e);
}
if let Some(frontend_url) = &oidc.frontend_base_url {
Redirect::temporary(frontend_url).into_response()
} else {
Redirect::temporary("/").into_response()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dot_path_to_json_pointer() {
use serde_json::json;
let cases = vec![
(
"realm_access.roles.0",
"/realm_access/roles/0",
json!({ "realm_access": { "roles": ["admin", "user"] } }),
"admin",
),
(
"preferred_username",
"/preferred_username",
json!({ "preferred_username": "bob" }),
"bob",
),
("a~b.c", "/a~0b/c", json!({ "a~b": { "c": "v" } }), "v"),
("a/b.c", "/a~1b/c", json!({ "a/b": { "c": "w" } }), "w"),
("~/.x", "/~0~1/x", json!({ "~/": { "x": "z" } }), "z"),
("a..b", "/a//b", json!({ "a": { "": { "b": "x" } } }), "x"),
("", "/", json!({ "": "root" }), "root"),
];
for (path, expected_ptr, json_val, expected_val) in cases {
let ptr = dot_path_to_json_pointer(path);
assert_eq!(ptr, expected_ptr, "Pointer mismatch for path: {}", path);
assert_eq!(
json_val.pointer(&ptr).and_then(|v| v.as_str()),
Some(expected_val),
"Value extraction failed for path: {}, pointer: {}",
path,
ptr
);
}
}
}
+184
View File
@@ -0,0 +1,184 @@
use axum::{
Json, Router,
extract::{Path, State},
http::StatusCode,
routing::post,
};
use axum_login::AuthUser as _;
use easytier::proto::rpc_types::controller::BaseController;
use crate::db::UserIdInDb;
use super::{AppState, HttpHandleError, other_error};
#[derive(Debug, serde::Deserialize)]
pub struct ProxyRpcRequest {
pub service_name: String,
pub method_name: String,
pub payload: serde_json::Value,
}
macro_rules! match_service {
($factory:ty, $method_name:expr, $payload:expr, $session:expr) => {{
let client = $session.scoped_client::<$factory>();
client
.json_call_method(BaseController::default(), &$method_name, $payload)
.await
}};
}
async fn handle_proxy_rpc_by_session(
session: &crate::client_manager::session::Session,
req: ProxyRpcRequest,
) -> Result<Json<serde_json::Value>, HttpHandleError> {
let ProxyRpcRequest {
service_name,
method_name,
payload,
} = req;
let resp = match service_name.as_str() {
"api.manage.WebClientService" => match_service!(
easytier::proto::api::manage::WebClientServiceClientFactory<BaseController>,
method_name,
payload,
session
),
"api.instance.PeerManageRpcService" => match_service!(
easytier::proto::api::instance::PeerManageRpcClientFactory<BaseController>,
method_name,
payload,
session
),
"api.instance.PeerCenterManageRpcService" => match_service!(
easytier::proto::peer_rpc::PeerCenterRpcClientFactory<BaseController>,
method_name,
payload,
session
),
"api.instance.ConnectorManageRpcService" => match_service!(
easytier::proto::api::instance::ConnectorManageRpcClientFactory<BaseController>,
method_name,
payload,
session
),
"api.instance.MappedListenerManageRpcService" => match_service!(
easytier::proto::api::instance::MappedListenerManageRpcClientFactory<BaseController>,
method_name,
payload,
session
),
"api.instance.VpnPortalRpcService" => match_service!(
easytier::proto::api::instance::VpnPortalRpcClientFactory<BaseController>,
method_name,
payload,
session
),
"api.instance.TcpProxyRpcService" => match_service!(
easytier::proto::api::instance::TcpProxyRpcClientFactory<BaseController>,
method_name,
payload,
session
),
"api.instance.AclManageRpcService" => match_service!(
easytier::proto::api::instance::AclManageRpcClientFactory<BaseController>,
method_name,
payload,
session
),
"api.instance.PortForwardManageRpcService" => match_service!(
easytier::proto::api::instance::PortForwardManageRpcClientFactory<BaseController>,
method_name,
payload,
session
),
"api.instance.StatsRpcService" => match_service!(
easytier::proto::api::instance::StatsRpcClientFactory<BaseController>,
method_name,
payload,
session
),
"api.instance.CredentialManageRpcService" => match_service!(
easytier::proto::api::instance::CredentialManageRpcClientFactory<BaseController>,
method_name,
payload,
session
),
"api.logger.LoggerRpcService" => match_service!(
easytier::proto::api::logger::LoggerRpcClientFactory<BaseController>,
method_name,
payload,
session
),
"api.config.ConfigRpcService" => match_service!(
easytier::proto::api::config::ConfigRpcClientFactory<BaseController>,
method_name,
payload,
session
),
_ => {
return Err((
StatusCode::BAD_REQUEST,
other_error(format!("Unknown service: {}", service_name)).into(),
));
}
};
match resp {
Ok(v) => Ok(Json(v)),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
other_error(format!("RPC Error: {:?}", e)).into(),
)),
}
}
pub async fn handle_proxy_rpc(
auth_session: super::users::AuthSession,
State(client_mgr): AppState,
Path(machine_id): Path<uuid::Uuid>,
Json(req): Json<ProxyRpcRequest>,
) -> Result<Json<serde_json::Value>, HttpHandleError> {
let user_id = auth_session
.user
.as_ref()
.ok_or((StatusCode::UNAUTHORIZED, other_error("Unauthorized").into()))?
.id();
let session = client_mgr
.get_session_by_machine_id(user_id, &machine_id)
.ok_or((
StatusCode::NOT_FOUND,
other_error("Session not found").into(),
))?;
handle_proxy_rpc_by_session(session.as_ref(), req).await
}
pub fn router() -> Router<super::AppStateInner> {
Router::new().route(
"/api/v1/machines/:machine-id/proxy-rpc",
post(handle_proxy_rpc),
)
}
/// Internal proxy-rpc handler: no AuthSession, resolves the active session by machine_id.
pub async fn handle_proxy_rpc_internal(
State(client_mgr): AppState,
Path((user_id, machine_id)): Path<(UserIdInDb, uuid::Uuid)>,
Json(req): Json<ProxyRpcRequest>,
) -> Result<Json<serde_json::Value>, HttpHandleError> {
let session = client_mgr
.get_session_by_machine_id(user_id, &machine_id)
.ok_or((
StatusCode::NOT_FOUND,
other_error("Session not found").into(),
))?;
handle_proxy_rpc_by_session(session.as_ref(), req).await
}
pub fn router_internal() -> Router<super::AppStateInner> {
Router::new().route(
"/api/internal/users/:user-id/machines/:machine-id/proxy-rpc",
post(handle_proxy_rpc_internal),
)
}
+42 -35
View File
@@ -4,8 +4,8 @@ use async_trait::async_trait;
use axum_login::{AuthUser, AuthnBackend, AuthzBackend, UserId};
use password_auth::verify_password;
use sea_orm::{
ActiveModelTrait as _, ColumnTrait, EntityTrait, FromQueryResult, IntoActiveModel, JoinType,
QueryFilter, QuerySelect as _, RelationTrait, Set, TransactionTrait,
ColumnTrait, EntityTrait, FromQueryResult, IntoActiveModel, JoinType, QueryFilter,
QuerySelect as _, RelationTrait, Set,
};
use serde::{Deserialize, Serialize};
use tokio::task;
@@ -14,7 +14,7 @@ use crate::db::{self, entity};
#[derive(Clone, Serialize, Deserialize)]
pub struct User {
db_user: entity::users::Model,
pub(crate) db_user: entity::users::Model,
pub tokens: Vec<String>,
}
@@ -39,9 +39,9 @@ impl AuthUser for User {
fn session_auth_hash(&self) -> &[u8] {
self.db_user.password.as_bytes() // We use the password hash as the auth
// hash--what this means
// is when the user changes their password the
// auth session becomes invalid.
// hash--what this means
// is when the user changes their password the
// auth session becomes invalid.
}
}
@@ -74,40 +74,47 @@ impl Backend {
Self { db }
}
pub fn db(&self) -> &db::Db {
&self.db
}
pub async fn register_new_user(&self, new_user: &RegisterNewUser) -> anyhow::Result<()> {
let hashed_password = password_auth::generate_hash(new_user.credentials.password.as_str());
let txn = self.db.orm_db().begin().await?;
entity::users::ActiveModel {
username: Set(new_user.credentials.username.clone()),
password: Set(hashed_password.clone()),
..Default::default()
}
.save(&txn)
.await?;
entity::users_groups::ActiveModel {
user_id: Set(entity::users::Entity::find()
.filter(entity::users::Column::Username.eq(new_user.credentials.username.as_str()))
.one(&txn)
.await?
.unwrap()
.id),
group_id: Set(entity::groups::Entity::find()
.filter(entity::groups::Column::Name.eq("users"))
.one(&txn)
.await?
.unwrap()
.id),
..Default::default()
}
.save(&txn)
.await?;
txn.commit().await?;
self.db
.create_user_and_join_users_group(&new_user.credentials.username, hashed_password)
.await?;
Ok(())
}
/// Find a user by username, or auto-create one for OIDC-authenticated users.
///
/// Unlike the heartbeat auto-creation path (controlled by `allow_auto_create_user`),
/// OIDC users are always provisioned automatically because their identity has already
/// been verified by a trusted external Identity Provider (IdP).
pub async fn find_or_create_oidc_user(&self, username: &str) -> anyhow::Result<User> {
use entity::users;
// Try to find an existing user first.
if let Some(db_user) = users::Entity::find()
.filter(users::Column::Username.eq(username))
.one(self.db.orm_db())
.await?
{
return Ok(User {
tokens: vec![db_user.username.clone()],
db_user,
});
}
// User not found auto-provision a local account backed by the IdP identity.
let db_user = self.db.auto_create_user(username).await?;
tracing::info!("Auto-provisioned OIDC user '{username}'");
Ok(User {
tokens: vec![db_user.username.clone()],
db_user,
})
}
pub async fn change_password(
&self,
id: <User as AuthUser>::Id,
+7 -7
View File
@@ -1,14 +1,15 @@
use axum::{
Router,
extract::State,
http::header,
response::{IntoResponse, Response},
routing, Router,
routing,
};
use axum_embed::ServeEmbed;
use easytier::common::scoped_task::ScopedTask;
use rust_embed::RustEmbed;
use std::net::SocketAddr;
use tokio::net::TcpListener;
use tokio_util::task::AbortOnDropHandle;
/// Embed assets for web dashboard, build frontend first
#[derive(RustEmbed, Clone)]
@@ -58,7 +59,7 @@ pub fn build_router(api_host: Option<url::Url>) -> Router {
pub struct WebServer {
bind_addr: SocketAddr,
router: Router,
serve_task: Option<ScopedTask<()>>,
serve_task: Option<AbortOnDropHandle<()>>,
}
impl WebServer {
@@ -70,14 +71,13 @@ impl WebServer {
})
}
pub async fn start(self) -> Result<ScopedTask<()>, anyhow::Error> {
pub async fn start(self) -> Result<AbortOnDropHandle<()>, anyhow::Error> {
let listener = TcpListener::bind(self.bind_addr).await?;
let app = self.router;
let task = tokio::spawn(async move {
let task = AbortOnDropHandle::new(tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
})
.into();
}));
Ok(task)
}
+186
View File
@@ -0,0 +1,186 @@
use std::sync::Arc;
use serde::{Deserialize, Serialize};
/// Webhook configuration for external integrations.
#[derive(Debug, Clone)]
pub struct WebhookConfig {
pub webhook_url: Option<String>,
pub webhook_secret: Option<String>,
pub internal_auth_token: Option<String>,
pub web_instance_id: Option<String>,
pub web_instance_api_base_url: Option<String>,
client: reqwest::Client,
}
impl WebhookConfig {
pub fn new(
webhook_url: Option<String>,
webhook_secret: Option<String>,
internal_auth_token: Option<String>,
web_instance_id: Option<String>,
web_instance_api_base_url: Option<String>,
) -> Self {
WebhookConfig {
webhook_url,
webhook_secret,
internal_auth_token,
web_instance_id,
web_instance_api_base_url,
client: reqwest::Client::new(),
}
}
pub fn is_enabled(&self) -> bool {
self.webhook_url
.as_deref()
.is_some_and(|url| !url.trim().is_empty())
}
pub fn has_internal_auth(&self) -> bool {
self.internal_auth_token.is_some()
}
}
// --- Request/Response types ---
#[derive(Debug, Serialize)]
pub struct ValidateTokenRequest {
pub token: String,
pub machine_id: String,
pub public_ip: Option<String>,
pub hostname: String,
pub version: String,
pub os_type: Option<String>,
pub os_version: Option<String>,
pub os_distribution: Option<String>,
pub web_instance_id: Option<String>,
pub web_instance_api_base_url: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ValidateTokenResponse {
pub valid: bool,
#[serde(default)]
pub pre_approved: bool,
#[serde(default)]
pub binding_version: u64,
pub managed_network_configs: Vec<ManagedNetworkConfig>,
pub config_revision: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ManagedNetworkConfig {
pub instance_id: String,
pub network_config: serde_json::Value,
}
#[derive(Debug, Serialize)]
pub struct NodeConnectedRequest {
pub machine_id: String,
pub token: String,
pub user_id: Option<i32>,
pub hostname: String,
pub version: String,
pub os_type: Option<String>,
pub os_version: Option<String>,
pub os_distribution: Option<String>,
pub web_instance_id: Option<String>,
pub binding_version: Option<u64>,
}
#[derive(Debug, Serialize)]
pub struct NodeDisconnectedRequest {
pub machine_id: String,
pub token: String,
pub user_id: Option<i32>,
pub web_instance_id: Option<String>,
pub binding_version: Option<u64>,
}
// --- Webhook client ---
impl WebhookConfig {
fn webhook_base_url(&self) -> anyhow::Result<&str> {
self.webhook_url
.as_deref()
.map(str::trim)
.filter(|url| !url.is_empty())
.ok_or_else(|| anyhow::anyhow!("webhook_url is not configured"))
}
fn webhook_endpoint(&self, path: &str) -> anyhow::Result<String> {
Ok(format!(
"{}/{}",
self.webhook_base_url()?.trim_end_matches('/'),
path.trim_start_matches('/'),
))
}
/// Validate a token through the configured webhook endpoint.
pub async fn validate_token(
&self,
req: &ValidateTokenRequest,
) -> anyhow::Result<ValidateTokenResponse> {
let url = self.webhook_endpoint("validate-token")?;
let resp = self
.client
.post(&url)
.header("X-Internal-Auth", self.webhook_auth_secret())
.json(req)
.send()
.await?;
if !resp.status().is_success() {
anyhow::bail!("webhook validate-token returned status {}", resp.status());
}
Ok(resp.json().await?)
}
/// Notify the webhook receiver that a node has connected.
pub async fn notify_node_connected(&self, req: &NodeConnectedRequest) {
if !self.is_enabled() {
return;
}
let Ok(url) = self.webhook_endpoint("webhook/node-connected") else {
tracing::warn!("skip node-connected webhook because webhook_url is not configured");
return;
};
let _ = self
.client
.post(&url)
.header("X-Internal-Auth", self.webhook_auth_secret())
.json(req)
.send()
.await;
}
/// Notify the webhook receiver that a node has disconnected.
pub async fn notify_node_disconnected(&self, req: &NodeDisconnectedRequest) {
if !self.is_enabled() {
return;
}
let Ok(url) = self.webhook_endpoint("webhook/node-disconnected") else {
tracing::warn!("skip node-disconnected webhook because webhook_url is not configured");
return;
};
let _ = self
.client
.post(&url)
.header("X-Internal-Auth", self.webhook_auth_secret())
.json(req)
.send()
.await;
}
fn webhook_auth_secret(&self) -> &str {
self.webhook_secret
.as_deref()
.or(self.internal_auth_token.as_deref())
.unwrap_or("")
}
}
pub type SharedWebhookConfig = Arc<WebhookConfig>;
+11
View File
@@ -0,0 +1,11 @@
disallowed-methods = [
{ path = "itertools::Itertools::map_into", reason = "Blocks underlying iterator optimizations. Use the native `.map(Into::into)` instead." },
{ path = "itertools::Itertools::map_ok", reason = "Blocks underlying iterator optimizations. Use the native `.map(|r| r.map(f))` instead." },
{ path = "itertools::Itertools::filter_ok", reason = "Blocks underlying iterator optimizations. Use a native approach, e.g., `.filter(|r| r.as_ref().map_or(true, condition))`." },
{ path = "itertools::Itertools::filter_map_ok", reason = "Blocks underlying iterator optimizations. Use native `.map()` and `.flatten()`, or extract logic into a standard `.filter_map()`." },
{ path = "itertools::Itertools::collect_vec", reason = "Non-standard idiom. Directly use the standard library's `.collect::<Vec<_>>()`." },
{ path = "itertools::Itertools::try_collect", reason = "Non-standard idiom. Standard `collect()` already supports Result/Option inversion; use `.collect::<Result<_, _>>()`." },
{ path = "itertools::Itertools::set_from", reason = "Non-standard idiom. Directly use the `.extend()` method provided by the standard library's `Extend` trait." },
{ path = "itertools::Itertools::concat", reason = "Non-standard idiom. Use native `.flatten().collect()` or a slice's `.concat()` instead." }
]

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