Compare commits

..
321 Commits
Author SHA1 Message Date
fanyang 09e30aeff6 fix(dns): adapt resolver chain to dns policy branch 2026-06-04 23:14:38 +08:00
fanyang e21046a5fa feat(dns): support configurable resolver chain 2026-06-04 23:14:38 +08:00
Luna Yao b207b8a7bb Merge branch 'main' into feat/dns-policy
# Conflicts:
#	Cargo.lock
#	easytier/build/main.rs
2026-05-15 14:56:13 +02:00
Luna YaoandGitHub 811f151155 refactor: rpc build (#2244)
rewrite rpc build with quota crate
2026-05-15 14:01:56 +08:00
Luna Yao 5e8a79a747 Cargo.lock 2026-05-14 02:37:34 +02:00
Luna Yao 8485091614 add RepeatedSerialize & RepeatedDeserialize 2026-05-14 02:37:16 +02:00
Luna Yao e31fee1a6d merge
Cargo.lock
2026-05-14 02:37:16 +02:00
Luna YaoandGitHub 743ec3c95e Merge branch 'main' into feat/dns-policy 2026-05-13 21:17:59 +02:00
Luna YaoandGitHub 8428a89d2d refactor: introduce HedgeExt for task hedging; rewrite NatDstQuicConnector (#2229) 2026-05-12 20:26:16 +08:00
513695297c [OHOS] feat: Enhance Rust kernel with config management and routing improvements (#2227)
* [OHOS.with ai] 将配置管理/配置分享/路由聚合/实例状态解析下沉至 Rust 内核,收敛职责并提升性能 (#2209)

* feat: add ohrs config store and startup error logging

* feat: full ability core for ohos

* feat: full ability core for ohos

* feat: clean code

---------

Co-authored-by: FrankHan <frankhan@FrankHans-Mac-mini.local>

* fix: 添加缺失文件

* fix: 修复更新路由启动两次TUN问题,并调整日志

* fix: rustfmt

* fix: 适配Cidr忽略/32格式路由

* fix: 修复Option适配错误

* fix: rustfmt

* fix: rustfmt

---------

Co-authored-by: FrankHan <frankhan@FrankHans-Mac-mini.local>
2026-05-10 14:15:31 +08:00
21paradoxandGitHub bfbfa2ef8d fix: reuse conn by dst_peer_id, every peer use only 1 quic conn, to fix nat lost problem (#2216) 2026-05-09 22:33:44 +08:00
KKRainbowandGitHub 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
fanyangandGitHub 55f15bb6f0 fix(connector): classify manual reconnect timeouts by stage (#2062) 2026-05-08 22:08:51 +08:00
Luna YaoandGitHub 96fd39649a revert UPX version to 4.2.4 in core.yml (#2221) 2026-05-07 18:49:40 +08:00
KKRainbowandGitHub 74fc8b300d chore: bump version to 2.6.4 (#2219) 2026-05-07 13:48:51 +08:00
KKRainbowandGitHub 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
fanyangandGitHub 4342c8d7a2 fix: add missing CLI help text (#2213) 2026-05-05 17:05:34 +08:00
KKRainbowandGitHub 1178b312fa fix foreign network entry leak (#2211) 2026-05-05 11:01:44 +08:00
Luna Yao 4c2859ae7c upgrade optionize to 0.2 2026-05-04 03:51:47 +02:00
Luna Yao c9e610a175 fmt 2026-05-04 02:06:50 +02:00
Luna Yao 22c458163a merge 2026-05-04 01:46:37 +02:00
Luna Yao ae83de91ab Merge branch 'main' into feat/dns-policy
# Conflicts:
#	easytier/build.rs
#	easytier/src/common/dns.rs
#	easytier/src/web_client/mod.rs
2026-05-04 01:44:54 +02:00
Luna Yao 348b83d768 use optionize 2026-05-04 01:33:57 +02:00
fanyangandGitHub 362aa7a9cd fix: allow omitted ACL config fields (#2206) 2026-05-04 00:47:24 +08:00
Luna Yao 4b95f44a91 replace derive_builder with bon
c'est meilleur

tauri
2026-05-01 21:53:17 +02:00
KKRainbowandGitHub 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
fanyangandGitHub 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
KKRainbowandGitHub 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
KKRainbowandGitHub 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
KKRainbowandGitHub 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
KKRainbowandGitHub 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
Luna Yao 96e2a6c775 config: use getset 2026-04-30 18:56:00 +02:00
Luna Yao 480677f085 node: increase heartbeat test wait time
heartbeat test
2026-04-30 18:56:00 +02:00
Luna Yao b7677031cb zone: fallthrough policy
heartbeat test

try_from
2026-04-30 18:56:00 +02:00
KKRainbowandGitHub 41b6d65604 fix faketcp filter on windows (#2190) 2026-04-30 23:55:56 +08:00
KKRainbowandGitHub aae30894dd fix: keep file logger disabled by default (#2189) 2026-04-30 21:42:30 +08:00
fanyangandGitHub 81d169abfc fix: fall back when CLI manage service is unavailable (#2185) 2026-04-30 19:50:50 +08:00
Luna YaoandGitHub 9c6c210e89 fix: disable SO_EXCLUSIVEADDRUSE on Windows (#2180) 2026-04-30 19:48:54 +08:00
Mg PigandGitHub d1c6dcf754 fix: prevent URL input layout flicker with container queries (#2186) 2026-04-30 19:45:01 +08:00
KKRainbowandGitHub 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
Luna Yao ee1f656d6b config: set name to optional 2026-04-29 12:25:18 +02:00
Luna Yao c866ed4da3 merge 2026-04-29 11:55:45 +02:00
Luna Yao 2fd8333ebb Merge branch 'main' into feat/dns-policy 2026-04-29 11:53:32 +02:00
Luna Yao 29ff4283d7 Cargo.lock 2026-04-29 11:44:43 +02:00
Luna Yao 8c0652a2e2 config: add disabled flag 2026-04-29 11:44:43 +02:00
Luna Yao 92af568d8b config: rewrite
test

test

origin

test

test
2026-04-29 11:44:22 +02:00
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
Luna Yao 0a23546e3f three_node 2026-04-28 21:54:05 +02:00
Luna Yao b85d5bc515 tests: ignore refresh error in wait_peer_zone_visibility
fmt
2026-04-28 21:23:17 +02:00
Luna Yao 30b88bf626 peer_mgr: invalidate unreachable peer
fmt
2026-04-28 21:23:16 +02:00
Luna Yao 486cc43059 node: remove serial_test
fmt
2026-04-28 21:23:16 +02:00
Luna Yao 46d1c2aef3 node & peer_mgr: fix rpc register/unregister
mut node
2026-04-28 21:23:16 +02:00
Luna Yao 8cf9d493b8 node: replace rr interval with heartbeat interval
interval
2026-04-28 21:23:16 +02:00
Luna Yao e527717eef node: add reconcile
logs

reconcile

reconcile
2026-04-28 21:23:16 +02:00
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 80041086de peer_mgr(refresh): add backoff retry
fmt

refresh
2026-04-28 15:03:53 +02:00
Luna Yao 40d3e48cc4 remove redundant heartbeat log 2026-04-28 14:52:02 +02:00
Luna Yao 88297868c3 node_mgr: append system zone handler to all origin
system
2026-04-28 05:12:27 +02:00
Luna Yao d59257c8b7 zone_handler: skip negative response to forward to upstream
catalog_lookup_returns_nodata_on_nameexists

fmt
2026-04-28 05:12:27 +02:00
Luna Yao fb4479a99f unit tests 2026-04-28 05:12:27 +02:00
Luna Yao 185777b741 server: add addresses to nic and bind to them
fmt

add address

save bindings
2026-04-28 05:12:27 +02:00
Luna Yao 7d536d3353 remove address hijack 2026-04-28 05:12:27 +02:00
Luna Yao 87f2905360 server: use guarden to protect system dns config
server: guarden

server guarden
2026-04-28 05:12:27 +02:00
Luna Yao 1338ab343c node(election): use guard to protect nic_packet_process_pipeline
node: guarden
2026-04-28 04:52:51 +02:00
Luna Yao 38e7366c15 merge 2026-04-27 20:45:20 +02:00
Luna Yao 0f4003245b Merge branch 'main' into feat/dns-policy
# Conflicts:
#	easytier/src/common/config.rs
#	easytier/src/common/dns.rs
#	easytier/src/common/mod.rs
#	easytier/src/core.rs
#	easytier/src/instance/instance.rs
#	easytier/src/launcher.rs
#	easytier/src/peers/peer_ospf_route.rs
#	easytier/src/proto/peer_rpc.proto
#	easytier/src/utils/task.rs
2026-04-27 17:16:43 +02:00
Luna YaoandGitHub d5c4700d32 utils: replace defer, ContextGuard, DetachableTask with guarden crate (#2163) 2026-04-27 18:29:46 +08:00
KKRainbowandGitHub 969ecfc4ca fix(gui): refresh service after core version upgrade (#2172) 2026-04-27 15:54:52 +08:00
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
KKRainbowandGitHub b20075e3dc fix: allow self virtual IP loopback (#2161) 2026-04-25 21:26:16 +08:00
Luna YaoandGitHub eb3b5aae51 utils: add DetachableTask & ContextGuard (#2138) 2026-04-25 18:24:36 +08:00
datayureiandGitHub af6b6ab6f1 fix: avoid panic when validating mapped listeners (#2153) 2026-04-25 17:45:57 +08:00
Luna YaoandGitHub 5a1668c753 refactor: remove ScopedTask (#2125)
* replace ScopedTask with AbortOnDropHandle
2026-04-25 15:20:25 +08:00
Luna YaoandGitHub 820d9095d3 replace AsyncRuntime with simpler CancellableTask (#2136) 2026-04-25 10:29:53 +08:00
KKRainbowandGitHub 2fb41ccbba bump version 262 (#2158) 2026-04-25 10:22:24 +08:00
Luna YaoandGitHub b4666be696 fix: disable SO_REUSEADDR & enable SO_EXCLUSIVEADDRUSE on Windows (#2128) 2026-04-25 00:37:34 +08:00
KKRainbowandGitHub 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 YaoandGitHub f7ea78d4f0 lower max_udp_payload_size to 1200 (#2156) 2026-04-24 21:20:37 +08:00
james.zhangandGitHub ac112440c3 fix(UrlInput): update parseUrl and buildUrlValue to handle null ports correctly (#2146) 2026-04-23 13:45:09 +08:00
KKRainbowandGitHub 958b246f05 improve webclient (#2151) 2026-04-23 13:44:18 +08:00
james.zhangandGitHub 263f4c3bc9 fix(peer_route): exclude current peer ID from proxy CIDR lists (#2149) 2026-04-22 20:30:38 +08:00
Luna YaoandGitHub 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 ChenandGitHub 5cd0a3e846 feat: add upnp support (#1449) 2026-04-21 17:19:04 +08:00
Luna YaoandGitHub 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 YaoandGitHub 0091a535d5 use mimalloc for FreeBSD (#2144) 2026-04-21 08:40:21 +08:00
Luna Yao 524c8d9987 move hostname function to utils 2026-04-20 00:51:59 +02:00
Luna YaoandGitHub d7a5fb8d66 remove --no-deps from lock check (#2134) 2026-04-20 00:46:26 +08:00
KKRainbowandGitHub f63054e937 fix: resolve Android APK version fallback to 1.0 on CI (#2131) 2026-04-19 19:06:37 +08:00
KKRainbowandGitHub efc043abbb bump version to v2.6.1 (#2129) 2026-04-19 16:49:45 +08:00
Luna Yao 11626d8082 remove id from zone 2026-04-19 02:17:50 +02:00
Luna Yao 362547eaab use localhost as default hostname 2026-04-19 01:48:08 +02:00
Luna Yao 5df7913043 plan: update 2026-04-19 01:38:30 +02:00
Luna Yao 9feb548bc2 remove fqdn from DnsConfig
l

l

plan: update

plan: update
2026-04-19 01:38:29 +02:00
Luna Yao cbb966d57c remove fqdn from DnsExportConfig 2026-04-19 00:34:57 +02:00
Luna Yao 6db0be00ad use sanitize in get_hostname
fmt

config
2026-04-19 00:34:57 +02:00
Luna Yao 185eb1e4c2 move sanitize and parse
fmt dns

dns utils

fmt

unit test

unit test
2026-04-19 00:34:55 +02:00
Luna Yao ae4e9b3513 rewrite resolver_conf, socket_addrs
comment

dns utils

res
2026-04-19 00:34:44 +02:00
Luna Yao 7ae7725fbf rewrite txt_lookup and srv_lookup
rewrite

rewrite

lookup

l
2026-04-19 00:34:41 +02:00
Luna Yao 5a516a195c move common::dns to utils::dns
move dns

move
2026-04-18 23:14:25 +02:00
Luna Yao c71ab7543f remove accept_dns 2026-04-18 21:26:30 +02:00
Luna Yao 4f6f8f3d93 use CancellableTask in DnsNode
remove AsyncRuntime from DnsNode

node

n

n

clippy
2026-04-18 21:26:30 +02:00
Luna Yao d5995ea1cb use CancellableTask in DnsServer
server
2026-04-18 17:18:36 +02:00
Luna Yao b0aae4f1fa replace AsyncRuntime with simpler CancellableTask
add docstring for AsyncRuntime

task

task
2026-04-18 17:18:36 +02:00
Luna Yao 4512e03d5f plan: update 2026-04-18 11:29:11 +02:00
Luna Yao 49e33f182b Merge branch 'main' into feat/dns-policy
# Conflicts:
#	easytier/Cargo.toml
2026-04-18 11:23:51 +02:00
Luna Yao bc86917dea add fallthrough flag
zone

test log
2026-04-18 04:18:31 +02:00
Luna Yao 9332baf6f9 rewrite NameServerAddr
fix Url to NameServerAddr conversion

fmt
2026-04-18 03:46:43 +02:00
Luna Yao 84984e1641 move dirty to crate::utils 2026-04-18 03:46:43 +02:00
Luna Yao 7345acfe7c upgrade hickory-dns to 0.26.0
fix zone test
2026-04-18 03:46:43 +02:00
Luna Yao 6001eef736 RoutePeerInfo 2026-04-17 19:11:31 +02:00
Luna Yao b070cff8e2 fmt 2026-04-17 18:59:11 +02:00
Luna Yao cbdfdfacc1 use cfg_select in dns::system::get 2026-04-17 18:55:13 +02:00
Luna Yao b94ab414a0 replace parking_lot with std 2026-04-17 18:50:12 +02:00
Luna Yao 3795800975 replace gethostname with hostname 2026-04-17 18:43:16 +02:00
Luna Yao ba7fc1098b merge 2026-04-17 18:26:21 +02:00
Luna Yao 38cb4a22fd Merge branch 'main' into feat/dns-policy 2026-04-17 17:46:20 +02:00
Luna Yao a60f101bda merge 2026-04-17 17:45:43 +02:00
Luna Yao 6b01554c17 Merge branch 'main' into feat/dns-policy
# Conflicts:
#	easytier/src/common/log.rs
#	easytier/src/connector/dns_connector.rs
#	easytier/src/dns/system/windows.rs
#	easytier/src/gateway/socks5.rs
#	easytier/src/instance/dns_server/server.rs
#	easytier/src/instance/dns_server/server_instance.rs
#	easytier/src/instance/instance.rs
#	easytier/src/peers/peer_manager.rs
#	easytier/src/peers/peer_ospf_route.rs
#	easytier/src/proto/utils.rs
#	easytier/src/tunnel/common.rs
#	easytier/src/utils/panic.rs
2026-04-17 17:16:55 +02:00
Luna Yao 5a8a1d3e6b disallow some methods from itertools 2026-04-06 18:28:30 +02:00
Luna Yao 6af62e939e constants
clippy
2026-04-06 17:38:17 +02:00
Luna Yao 012b35acd6 arrange tests
comment
2026-04-06 17:36:44 +02:00
Luna Yao 34dab9c739 node: comments 2026-04-06 17:04:30 +02:00
Luna Yao 09b26fd4fe zone: comments 2026-04-06 17:04:30 +02:00
Luna Yao 50ecd3679b peer_mgr: remove peer from cache when lost route 2026-04-06 17:04:30 +02:00
Luna Yao ddbfe76932 log: test log 2026-04-06 17:04:30 +02:00
Luna Yao 847e82ff8c config: filter out disabled export 2026-04-06 17:04:29 +02:00
Luna Yao 501c2bdb73 test: integration test
test: integration tests

test: integration
2026-04-06 17:04:29 +02:00
Luna Yao 8c5851e2ee server: unit tests 2026-04-06 16:19:25 +02:00
Luna Yao 7d47cc837f zone: unit tests 2026-04-06 16:19:25 +02:00
Luna Yao b422e603c8 node_mgr: unit tests 2026-04-06 16:19:25 +02:00
Luna Yao ee217c51f6 peer_mgr: unit tests 2026-04-06 16:19:25 +02:00
Luna Yao 4f180ab309 move new_request to tests 2026-04-06 11:55:25 +02:00
Luna Yao 248117c119 node: unit tests 2026-04-06 11:55:25 +02:00
Luna Yao 4dad60a94d replace ttl with tti 2026-04-06 11:55:25 +02:00
Luna Yao 67108d14b0 test: new_request 2026-04-06 11:55:25 +02:00
Luna Yao 9e8a4c94cb structured log 2026-04-06 11:55:25 +02:00
Luna Yao 8610234ee2 server: clean up on stop
server
2026-04-06 11:55:25 +02:00
Luna Yao b51b17e229 plan: update 2026-04-06 11:55:25 +02:00
Luna Yao f17ee9244c node: join_joinset_background
fmt

join
2026-04-06 11:55:25 +02:00
Luna Yao 0cd92ff215 zone: move iter_records to test 2026-04-06 11:55:25 +02:00
Luna Yao 4ed111dd04 zone: remove unused PartialEq 2026-04-06 11:55:25 +02:00
Luna Yao 2b3417d703 remove unneeded pub 2026-04-06 11:55:25 +02:00
Luna Yao b7edbad4b9 utils(authority): delegate 2026-04-06 11:55:25 +02:00
Luna Yao a6988e55f6 zone: some tests 2026-04-06 11:55:25 +02:00
Luna Yao 1562092afe build: remove redundant protoc arg 2026-04-06 11:55:25 +02:00
Luna Yao d5c68e356a utils(response): clear inner before writing 2026-04-06 11:55:25 +02:00
Luna Yao a9facedf57 peer_mgr: remove redundant * when comp Vec 2026-04-06 11:55:25 +02:00
Luna Yao faa252fc39 utils(dirty): replace Notify with watch 2026-04-06 11:55:25 +02:00
Luna Yao a031b6f701 server: rearrange method ordering 2026-04-06 11:55:25 +02:00
Luna Yao 41d7335bd8 server: save listeners after reloading 2026-04-06 11:55:25 +02:00
Luna Yao 2a2f85397c test(relay_peer_e2e_encryption): wait for key before ping test 2026-04-06 11:55:25 +02:00
Luna Yao 18dc9e4ca3 instance: drop dns in Drop 2026-04-06 11:55:25 +02:00
Luna Yao 39c595b5e9 instance: drop peer_mgr in time 2026-04-06 11:55:22 +02:00
Luna Yao 604386f7e2 peer_mgr: unregister rpc in Drop 2026-04-06 11:54:51 +02:00
Luna Yao 0d91aaf974 clippy 2026-04-06 11:54:51 +02:00
Luna Yao 1b220dd825 peer_mgr: add register() for rpc 2026-04-06 11:54:51 +02:00
Luna Yao e4f2078ff1 change heartbeat log level to trace 2026-04-06 11:54:50 +02:00
Luna Yao 62b512fbae stop dns in Drop of Instance 2026-04-06 11:54:50 +02:00
Luna Yao 5f8548d272 make dns Option 2026-04-06 11:54:50 +02:00
Luna Yao 9b18f820ad clippy 2026-04-06 11:54:50 +02:00
Luna Yao 56bd8491d1 fix tun feature gate
fmt
2026-04-06 11:54:50 +02:00
Luna Yao 7cb4345e4d fmt 2026-04-06 11:54:50 +02:00
Luna Yao ea5e34b6af Revert "remove hostname from config" 2026-04-06 11:54:50 +02:00
Luna Yao c8697e76b4 remove magic-dns proto
rm magic dns

rm magic dns
2026-04-06 11:54:50 +02:00
Luna Yao 385ff54b3e fix magic-dns feature gate 2026-04-06 11:54:50 +02:00
Luna Yao d94cb3a96d peer_mgr: ignore unsupported peer 2026-04-06 11:54:50 +02:00
Luna Yao b6afe6305a format 2026-04-06 11:54:50 +02:00
Luna Yao 7c895c401b fix: mobile, macos
macos
2026-04-06 11:54:50 +02:00
Luna Yao 8da5dc5f6e add readme
readme: intro

readme: part 1

readme: part 2
2026-04-06 11:54:50 +02:00
Luna Yao 561bbd2d62 node: remove DnsNodeRuntime 2026-04-06 11:54:50 +02:00
Luna Yao 23f63f1e4a log & clippy 2026-04-06 11:54:50 +02:00
Luna Yao cddf6abfa7 cargo: update indexmap 2026-04-06 11:54:50 +02:00
Luna Yao 0733a1029c system: update windows test 2026-04-06 11:54:50 +02:00
Luna Yao cb1e5ca273 utils: From<&NameServerConfig> for NameServerAddr 2026-04-06 11:54:50 +02:00
Luna Yao 7286a42d58 proxy_cidrs_monitor: remove get_route_peer_info_last_update_time check 2026-04-06 11:54:50 +02:00
Luna Yao 28f26a1750 node_mgr: simplify collect_zones 2026-04-06 11:54:50 +02:00
Luna Yao 96b9d89191 node_mgr: use into_groups and iter_authorities 2026-04-06 11:54:50 +02:00
Luna Yao 58c7828c85 authority: add ArcAuthority, add into_groups & iter_authorities 2026-04-06 11:54:50 +02:00
Luna Yao c9ce17d25e zone::tests: catalog test 2026-04-06 11:54:50 +02:00
Luna Yao 231b7d8300 node_mgr: fix catalog creation, step 1 2026-04-06 11:54:50 +02:00
Luna Yao ffa7ca3850 node_mgr: test 2026-04-06 11:54:50 +02:00
Luna Yao 5b4ec2f56b utils: move ResponseHandle 2026-04-06 11:54:50 +02:00
Luna Yao 723fc90691 utils: move ResponseHandle 2026-04-06 11:54:50 +02:00
Luna Yao dcd0c7cc2f utils: move ResponseHandle 2026-04-06 11:54:50 +02:00
Luna Yao 2b94df8320 node_mgr: zone ordering 2026-04-06 11:54:50 +02:00
Luna Yao 447e15f478 zone 2026-04-06 11:54:50 +02:00
Luna Yao d5d600a524 remove hostname from config 2026-04-06 11:54:50 +02:00
Luna Yao 56f430bf10 add ttl to ZoneData 2026-04-06 11:54:50 +02:00
Luna Yao 09ba9a1203 node_mgr: heartbeat log
node_mgr log
2026-04-06 11:54:50 +02:00
Luna Yao 4ff9e0f8d7 dns: tests mod 2026-04-06 11:54:50 +02:00
Luna Yao 5bc0c1b2ff zone: add empty check for forward config
zone
2026-04-06 11:54:50 +02:00
Luna Yao 3fb1da6c2e node & peer_mgr: refactor refresh logic, add my_peer_id check
node
2026-04-06 11:54:50 +02:00
Luna Yao 725cdf7d06 server: skip reload if unchanged 2026-04-06 11:54:50 +02:00
Luna Yao 484c264444 server: remove port of address for system config 2026-04-06 11:54:50 +02:00
Luna Yao fe43b5706f dirty: fix Default, remove unused DirtyState 2026-04-06 11:54:50 +02:00
Luna Yao 0ca5e38dba log: span
log: format
2026-04-06 11:54:50 +02:00
Luna Yao b0e4808d00 log: add test log init
log
2026-04-06 11:54:50 +02:00
Luna Yao 2a34032221 log: refactor 2026-04-06 11:54:50 +02:00
Luna Yao 15cc86c8bc log: use test writer 2026-04-06 11:54:50 +02:00
Luna Yao 0e5d2cbf08 node & server: add log
server & node: log

node & server: instrument
2026-04-06 11:54:50 +02:00
Luna Yao 8cb9856f09 server: add test 2026-04-06 11:54:50 +02:00
Luna Yao 97d534a8c0 utils: add state to AsyncRuntime
utils: format
2026-04-06 11:54:50 +02:00
Luna Yao 093cd7c197 server: add register method
node: format

node & server: fix rpc ownership
2026-04-06 11:54:50 +02:00
Luna Yao 6c47b0d86c plan: update 2026-04-06 11:54:50 +02:00
Luna Yao 4f3244636d instance: add dns 2026-04-06 11:54:50 +02:00
Luna Yao 2f04de9420 server: rename; hide ResponseHandle inner 2026-04-06 11:54:50 +02:00
Luna Yao e0173a52da server: use AsyncRuntime 2026-04-06 11:54:50 +02:00
Luna Yao 08a1640e30 server: lifecycle 2026-04-06 11:54:50 +02:00
Luna Yao 70fba64e17 node: lifecycle 2026-04-06 11:54:50 +02:00
Luna Yao 95e460dbf5 utils: add AsyncRuntime
utils: fix AsyncRuntime lock
2026-04-06 11:54:50 +02:00
Luna Yao a73a029c50 server: update system dns settings on address reload 2026-04-06 11:54:50 +02:00
Luna Yao d9d211c5a4 remove old magic-dns 2026-04-06 11:54:47 +02:00
Luna Yao 8559de1857 system: add get; rename 2026-04-06 11:54:02 +02:00
Luna Yao 18e28197b8 node: adjust field ordering 2026-04-06 11:54:02 +02:00
Luna Yao e01f2c22a8 plan: update 2026-04-06 11:54:02 +02:00
Luna Yao 918c9b9174 system: rename SystemConfig to SystemConfigurator, add Clone to SystemConfigurator 2026-04-06 11:54:02 +02:00
Luna Yao 4ed9cbc4a1 move system_config to dns::system 2026-04-06 11:54:02 +02:00
Luna Yao 202d8ec121 server: Drop 2026-04-06 11:54:02 +02:00
Luna Yao 833ed8d8b8 node: save global_ctx as a field 2026-04-06 11:54:02 +02:00
Luna Yao a44a98d7c4 plan: update 2026-04-06 11:54:02 +02:00
Luna Yao 86871b14d5 store DnsServer in GlobalCtx, load routes from DnsServer in ProxyCidrsMonitor 2026-04-06 11:54:02 +02:00
Luna Yao 3fc030de48 server: use parking_lot::RwLock for addresses 2026-04-06 11:54:02 +02:00
Luna Yao b5a33a192b node: use join in election 2026-04-06 11:54:02 +02:00
Luna Yao 9d1f134f9e plan: update 2026-04-06 11:54:02 +02:00
Luna Yao 57a2428f21 node: add server election
peer_mgr

server: add peer_mgr

peer_mgr
2026-04-06 11:54:02 +02:00
Luna Yao 238858ebab server: use select! for run 2026-04-06 11:54:02 +02:00
Luna Yao 0c8da66a74 node: add Notify for server election 2026-04-06 11:54:02 +02:00
Luna Yao 14155d706c server: cleanup resources in run and drop 2026-04-06 11:54:02 +02:00
Luna Yao c968fb6d06 server: impl Drop for DnsServerRuntime 2026-04-06 11:54:02 +02:00
Luna Yao 6618635622 server: impl NicPacketFilter 2026-04-06 11:54:02 +02:00
Luna Yao 30cfe7a74a server: use bind_socket 2026-04-06 11:54:02 +02:00
Luna Yao 13275aae99 move bind_tcp_socket and bind_udp_socket to common 2026-04-06 11:54:02 +02:00
Luna Yao 2e1f9bc9cc node & server: separate dirty flag, remove DirtyState 2026-04-06 11:54:02 +02:00
Luna Yao fcfb0ad6bc plan: update 2026-04-06 11:54:02 +02:00
Luna Yao e967434ef9 node: refresh on PeerInfoUpdated 2026-04-06 11:54:02 +02:00
Luna Yao f75c35d4a0 add GlobalCtxEvent::PeerInfoUpdated, issue this event in do_sync_route_info
fix use in peer_ospf_route.rs
2026-04-06 11:54:02 +02:00
Luna Yao 088628c1b6 plan: update 2026-04-06 11:54:02 +02:00
Luna Yao 9b372d3087 node: watch config/ip change 2026-04-06 11:54:02 +02:00
Luna Yao 5de87cd494 plan: update 2026-04-06 11:54:02 +02:00
Luna Yao f5d3443928 client: rename to node 2026-04-06 11:54:01 +02:00
Luna Yao 5372596432 client: replace spawn_local with spawn 2026-04-06 11:54:01 +02:00
Luna Yao 5457adb533 utils: move to a mod 2026-04-06 11:54:01 +02:00
Luna Yao 474f1a3636 client: remove DnsPeerMgrDirtyFlags 2026-04-06 11:54:01 +02:00
Luna Yao efcdf4c456 utils: add DirtyState 2026-04-06 11:54:01 +02:00
Luna Yao e9a0e4f042 server: add new 2026-04-06 11:54:01 +02:00
Luna Yao d54eb97201 server: rename zones dirty flag to catalog 2026-04-06 11:54:01 +02:00
Luna Yao dc1e1929c8 client_mgr: add
format

client_mgr
2026-04-06 11:54:01 +02:00
Luna Yao b7073504d8 peer_mgr: use flat_map 2026-04-06 11:54:01 +02:00
Luna Yao f83816ac16 server: remove comments 2026-04-06 11:54:01 +02:00
Luna Yao d3ca03e20e use notify in dirty states 2026-04-06 11:54:01 +02:00
Luna Yao 3d3dba28e8 utils: add DirtyFlag 2026-04-06 11:54:01 +02:00
Luna Yao e3ed91aced peer_mgr: format 2026-04-06 11:54:01 +02:00
Luna Yao 8f7fc6e07b server: remove Notify of dirty 2026-04-06 11:54:01 +02:00
Luna Yao 5e69a26a53 server: add DnsServerRuntime and event loop 2026-04-06 11:54:01 +02:00
Luna Yao 488dfaf233 client: fix atomic ordering 2026-04-06 11:54:01 +02:00
Luna Yao 4a18a86ed6 proto: rename DnsPeerMgrRpc 2026-04-06 11:54:01 +02:00
Luna Yao a8fa7be4ce server: use ArcGlobalCtx 2026-04-06 11:54:01 +02:00
Luna Yao b469a28e19 server: use HashSet in DnsClientInfo 2026-04-06 11:54:01 +02:00
Luna Yao a5fdebe778 chore: move constants to mod.rs, format 2026-04-06 11:54:01 +02:00
Luna Yao 87713e96b7 chore: move all config to a dedicated mod 2026-04-06 11:54:01 +02:00
Luna Yao f9108672e6 server: add DnsClientInfo 2026-04-06 11:54:01 +02:00
Luna Yao dbb2f86a25 zone: add PartialEq 2026-04-06 11:54:01 +02:00
Luna Yao 0fdfbc2636 utils: use itertools
utils
2026-04-06 11:54:01 +02:00
Luna Yao 2cd208fad4 peer_mgr: remove DnsSnapshot, store zones directly in DnsPeerInfo 2026-04-06 11:54:01 +02:00
Luna Yao e0897dcf99 proto: add update method to HeartbeatRequest 2026-04-06 11:54:01 +02:00
Luna Yao 13aaa437b2 config: use itertools 2026-04-06 11:54:01 +02:00
Luna Yao 6856649705 client: remove Heartbeat 2026-04-06 11:54:01 +02:00
Luna Yao 35b27b73a2 zone: make Zone a model of ZoneData
zone: rename ZoneData back to Zone
2026-04-06 11:54:01 +02:00
Luna Yao 97edee20f9 config: add a wrapper for ZoneConfig, use Derivative to impl Default for DnsConfig
zone: make Zone a model of ZoneData

zone: rename ZoneData back to Zone
2026-04-06 11:54:01 +02:00
Luna Yao 743d8e4810 client: remove Derivative from DnsClient 2026-04-06 11:54:01 +02:00
Luna Yao 129f696a65 proto: remove ttl from ZoneData 2026-04-06 11:54:01 +02:00
Luna Yao 90409bfcaa utils: use itertools 2026-04-06 11:54:01 +02:00
Luna Yao f53ced6281 utils: update MapTryInto to match behaviour of itertools 2026-04-06 11:54:01 +02:00
Luna Yao e4bf2ca959 utils: add NameServerConfigGroup conversion impls 2026-04-06 11:54:01 +02:00
Luna Yao fd39961567 peer_mgr: remove Derivative 2026-04-06 11:54:01 +02:00
Luna Yao 9502a83143 dns: change mod to pub 2026-04-06 11:54:01 +02:00
Luna Yao 822e742ee2 server: add 2026-04-06 11:54:01 +02:00
Luna Yao 85c06b0758 proto: rename ZoneConfigPb to ZoneData 2026-04-06 11:54:01 +02:00
Luna Yao 7041214321 client: add Heartbeat 2026-04-06 11:54:01 +02:00
Luna Yao 9caeeafbf9 config: use NameServerAddr for addresses 2026-04-06 11:54:01 +02:00
Luna Yao 9a2ba9cce8 peer_mgr: add DnsSnapshot Model 2026-04-06 11:54:01 +02:00
Luna Yao 2caf492551 zone: add system, remove add_forwarders 2026-04-06 11:54:01 +02:00
Luna Yao 4f3afc8ee8 utils: update NameServerAddr conversion 2026-04-06 11:54:01 +02:00
Luna Yao 863fc6f4cc utils: add MapTryInto 2026-04-06 11:54:01 +02:00
Luna Yao de84adb3a6 zone: add id to display 2026-04-06 11:54:01 +02:00
Luna Yao a9dc19ce76 proto: update Url conversion
url

url

fix url conversion
2026-04-06 11:54:01 +02:00
Luna Yao 8076734cd4 utils: add RepeatedMessageModel 2026-04-06 11:54:01 +02:00
Luna Yao 888bec6eac utils: move DeterministicDigest to utils 2026-04-06 11:54:01 +02:00
Luna Yao 7b1b651a46 peer_mgr: rename cache to peers, extract ttl as a const 2026-04-06 11:54:01 +02:00
Luna Yao 070332b9a7 plan: rename to ChainedAuthority 2026-04-06 11:54:01 +02:00
Luna Yao a6bec0fa6f zone: move chained authority to utils, rewrite authority creation 2026-04-06 11:54:01 +02:00
Luna Yao 77ccd7dbc9 zone: remove unused function 2026-04-06 11:54:01 +02:00
Luna Yao a6d030494c client: fix heartbeat initialization 2026-04-06 11:54:01 +02:00
Luna Yao 2ca8b5f8a2 server: add mod 2026-04-06 11:54:01 +02:00
Luna Yao 0e3474f049 peer_mgr: moved to a standalone file 2026-04-06 11:54:01 +02:00
Luna Yao 28c4c7b4a0 plan: remove delta, add DnsRunner, refactor heartbeat 2026-04-06 11:54:01 +02:00
Luna Yao 2e68a8db91 client: rewrite heartbeat, add resync 2026-04-06 11:54:01 +02:00
Luna Yao 7f8f036596 client: add DnsPeerManager and rpc 2026-04-06 11:54:01 +02:00
Luna Yao c4cf8cf1d8 rpc: let peers generate their own dedicated zones 2026-04-06 11:54:01 +02:00
Luna Yao cff0264caf chore: format 2026-04-06 11:54:01 +02:00
Luna Yao 2b5b80e179 client: framework 2026-04-06 11:54:01 +02:00
Luna Yao 9d48cefd05 config: let get_name return LowerName 2026-04-06 11:54:01 +02:00
Luna Yao 6836d61127 utils: add a parse function for DNS name 2026-04-06 11:54:01 +02:00
Luna Yao 75ad15c324 utils: add From<&NameServerAddr> for Url 2026-04-06 11:54:01 +02:00
Luna Yao 83bdcf0d2b rpc: add digest 2026-04-06 11:54:01 +02:00
Luna Yao ee3b7f2335 client: add mod 2026-04-06 11:54:01 +02:00
Luna Yao 7e16b7f69f plan: store only hash of DNS in RoutePeerInfo 2026-04-06 11:54:01 +02:00
Luna Yao 9376d3959b config: update test for policies 2026-04-06 11:54:01 +02:00
Luna Yao 78b2afb785 config: add DNS import/export policies
config: move ID generation to ZoneConfig

config: serde fixes
2026-04-06 11:54:01 +02:00
Luna Yao 8a93bb311b add FallbackAuthority and related test 2026-04-06 11:54:01 +02:00
Luna Yao b24bb42faa add dns to RoutePeerInfo 2026-04-06 11:54:01 +02:00
Luna Yao 2eaa5c215d add zone module with Zone struct and related functionality 2026-04-06 11:54:01 +02:00
Luna Yao 3f3aec8edd add DnsConfig and ZoneConfig with proto definitions 2026-04-06 11:54:01 +02:00
Luna Yao 7abdfd35e8 add dns mod 2026-04-06 11:54:01 +02:00
Luna Yao d3454febb7 plan: store only hash in RoutePeerInfo 2026-04-06 11:54:01 +02:00
Luna Yao 63dd133f0b plan: add alternative plan 2026-04-06 11:54:01 +02:00
Luna Yao 32f86e421a plan: remove delta update 2026-04-06 11:54:01 +02:00
Luna Yao 1c4303f5b7 plan: simplify RPC 2026-04-06 11:54:01 +02:00
Luna Yao 07376eb690 plan: add zone-wise acl control
plan: fix config example
2026-04-06 11:54:01 +02:00
Luna Yao 0b6ff1ecd7 plan: replace timestamp by id & seq 2026-04-06 11:54:01 +02:00
Luna Yao 46b53c601e add dev plan 2026-04-06 11:54:01 +02:00
214 changed files with 28351 additions and 5201 deletions
+5 -2
View File
@@ -157,6 +157,9 @@ jobs:
- uses: mlugg/setup-zig@v2
if: ${{ contains(matrix.OS, 'ubuntu') }}
with:
version: 0.16.0
use-cache: true
- uses: taiki-e/install-action@v2
if: ${{ contains(matrix.OS, 'ubuntu') }}
@@ -178,7 +181,7 @@ jobs:
BUILD=zigbuild
fi
if [[ "$TARGET" =~ ^(riscv64|loongarch64|aarch64).*$ || "$TARGET" =~ windows ]]; then
if [[ "$TARGET" =~ ^(riscv64|loongarch64|aarch64).*$ || "$TARGET" =~ (freebsd|windows) ]]; then
FEATURES="mimalloc"
else
FEATURES="jemalloc"
@@ -227,7 +230,7 @@ jobs:
*) UPX_ARCH="amd64" ;;
esac
UPX_VERSION=5.1.1
UPX_VERSION=4.2.4
UPX_PKG="upx-${UPX_VERSION}-${UPX_ARCH}_linux"
curl -L "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/${UPX_PKG}.tar.xz" -s | tar xJvf -
cp "${UPX_PKG}/upx" .
+1 -1
View File
@@ -11,7 +11,7 @@ on:
image_tag:
description: 'Tag for this image build'
type: string
default: 'v2.6.0'
default: 'v2.6.4'
required: true
mark_latest:
description: 'Mark this image as latest'
+2 -2
View File
@@ -18,7 +18,7 @@ on:
version:
description: 'Version for this release'
type: string
default: 'v2.6.0'
default: 'v2.6.4'
required: true
make_latest:
description: 'Mark this release as latest'
@@ -92,4 +92,4 @@ jobs:
files: |
./zipped_assets/*
token: ${{ secrets.GITHUB_TOKEN }}
tag_name: ${{ inputs.version }}
tag_name: ${{ inputs.version }}
+14 -7
View File
@@ -58,23 +58,26 @@ jobs:
- uses: taiki-e/install-action@cargo-hack
- name: Check Cargo.lock is up to date
run: |
if ! cargo metadata --format-version 1 --locked --no-deps > /dev/null; then
echo "::error::Cargo.lock is out of date. Run cargo generate-lockfile or cargo build locally, then commit Cargo.lock."
exit 1
fi
- name: Check formatting
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
@@ -125,6 +128,10 @@ jobs:
- 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: |
Generated
+768 -295
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -3,7 +3,6 @@ resolver = "2"
members = [
"easytier",
"easytier-gui/src-tauri",
"easytier-rpc-build",
"easytier-web",
"easytier-contrib/easytier-ffi",
"easytier-contrib/easytier-uptime",
+3 -3
View File
@@ -108,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.6.0-70e69a38~ |
| 10.126.126.2 | abc-2 | p2p | 3.452 | 0 | 17.33 kB | 20.42 kB | udp | FullCone | 390879727 | 2.6.0-70e69a38~ |
| | PublicServer_a | p2p | 27.796 | 0.000 | 50.01 kB | 67.46 kB | tcp | Unknown | 3771642457 | 2.6.0-70e69a38~ |
| 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:
+3 -3
View File
@@ -108,9 +108,9 @@ sudo easytier-core -d --network-name abc --network-secret abc -p tcp://<共享
```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.6.0-70e69a38~ |
| 10.126.126.2 | abc-2 | p2p | 3.452 | 0 | 17.33 kB | 20.42 kB | udp | FullCone | 390879727 | 2.6.0-70e69a38~ |
| | PublicServer_a | p2p | 27.796 | 0.000 | 50.01 kB | 67.46 kB | tcp | Unknown | 3771642457 | 2.6.0-70e69a38~ |
| 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~ |
```
您可以测试节点之间的连通性:
+1 -1
View File
@@ -1,6 +1,6 @@
id=easytier_magisk
name=EasyTier_Magisk
version=v2.6.0
version=v2.6.4
versionCode=1
author=EasyTier
description=easytier magisk module @EasyTier(https://github.com/EasyTier/EasyTier)
+544 -132
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -7,6 +7,10 @@ edition = "2024"
crate-type=["cdylib"]
[dependencies]
async-trait = "0.1"
base64 = "0.22"
flate2 = "1.1"
gethostname = "1.1"
ohos-hilog-binding = {version = "*", features = ["redirect"]}
easytier = { path = "../../easytier" }
napi-derive-ohos = "1.1"
@@ -26,10 +30,16 @@ napi-ohos = { version = "1.1", default-features = false, features = [
"web_stream",
] }
once_cell = "1.21.3"
ipnet = "2.10"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.125"
prost-reflect = { version = "0.14.5", default-features = false, features = ["derive"] }
rusqlite = { version = "0.32", features = ["bundled"] }
tracing-subscriber = "0.3.19"
tracing-core = "0.1.33"
tracing = "0.1.41"
tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] }
url = "2.5"
uuid = { version = "1.5.0", features = [
"v4",
"fast-rng",
@@ -0,0 +1,4 @@
pub(crate) mod repository;
pub(crate) mod services;
pub(crate) mod storage;
pub(crate) mod types;
@@ -0,0 +1,13 @@
#[path = "../../config_repo/field_store.rs"]
mod field_store;
#[path = "../../config_repo/import_export.rs"]
mod import_export;
#[path = "../../config_repo/legacy_migration.rs"]
mod legacy_migration;
#[path = "../../config_repo/validation.rs"]
mod validation;
#[path = "../../config_repo.rs"]
mod repo;
pub use repo::*;
@@ -0,0 +1,2 @@
pub(crate) mod schema_service;
pub(crate) mod share_link_service;
@@ -0,0 +1,414 @@
use easytier::proto::ALL_DESCRIPTOR_BYTES;
use napi_derive_ohos::napi;
use once_cell::sync::Lazy;
use prost_reflect::{Cardinality, DescriptorPool, FieldDescriptor, Kind, MessageDescriptor};
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
#[napi(object)]
pub struct FieldOption {
pub label: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize)]
#[napi(object)]
pub struct ValidationRule {
pub rule_type: String,
pub arg: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize)]
#[napi(object)]
pub struct NetworkConfigSchema {
pub node_kind: String,
pub name: String,
pub field_number: i32,
pub type_name: Option<String>,
pub semantic_type: Option<String>,
pub value_kind: String,
pub is_list: bool,
pub required: bool,
pub default_value_text: Option<String>,
pub enum_options: Vec<FieldOption>,
pub validations: Vec<ValidationRule>,
pub children: Vec<NetworkConfigSchema>,
pub definitions: Vec<NetworkConfigSchema>,
}
#[derive(Debug, Clone, Serialize)]
#[napi(object)]
pub struct ConfigFieldMapping {
pub field_name: String,
pub field_number: i32,
}
static DESCRIPTOR_POOL: Lazy<DescriptorPool> = Lazy::new(|| {
DescriptorPool::decode(ALL_DESCRIPTOR_BYTES)
.expect("easytier descriptor pool should decode from embedded protobuf descriptors")
});
const NETWORK_CONFIG_MESSAGE_NAME: &str = "api.manage.NetworkConfig";
fn descriptor_pool() -> &'static DescriptorPool {
&DESCRIPTOR_POOL
}
fn network_config_descriptor() -> MessageDescriptor {
descriptor_pool()
.get_message_by_name(NETWORK_CONFIG_MESSAGE_NAME)
.expect("api.manage.NetworkConfig descriptor should exist")
}
fn field_default_value_text(field: &FieldDescriptor) -> Option<String> {
if field.is_list() || field.is_map() {
return Some("[]".to_string());
}
match field.kind() {
Kind::Bool => Some("false".to_string()),
Kind::String => Some("\"\"".to_string()),
Kind::Bytes => Some("\"\"".to_string()),
Kind::Int32
| Kind::Sint32
| Kind::Sfixed32
| Kind::Int64
| Kind::Sint64
| Kind::Sfixed64
| Kind::Uint32
| Kind::Fixed32
| Kind::Uint64
| Kind::Fixed64
| Kind::Float
| Kind::Double => Some("0".to_string()),
Kind::Enum(enum_desc) => enum_desc
.get_value(0)
.map(|value| value.number().to_string()),
Kind::Message(_) => None,
}
}
fn field_type_name(field: &FieldDescriptor) -> Option<String> {
match field.kind() {
Kind::Enum(enum_desc) => Some(enum_desc.full_name().to_string()),
Kind::Message(message_desc) => Some(message_desc.full_name().to_string()),
_ => None,
}
}
fn field_semantic_type(field: &FieldDescriptor) -> Option<String> {
match field.name() {
"virtual_ipv4" => Some("cidr_ip".to_string()),
"network_length" => Some("cidr_mask".to_string()),
"peer_urls" => Some("peer[]".to_string()),
"proxy_cidrs" => Some("cidr[]".to_string()),
"listener_urls" => Some("listener[]".to_string()),
"routes" => Some("route[]".to_string()),
"exit_nodes" => Some("ip[]".to_string()),
"relay_network_whitelist" => Some("network_name[]".to_string()),
"mapped_listeners" => Some("mapped_listener[]".to_string()),
"port_forwards" => Some("port_forward[]".to_string()),
_ => None,
}
}
fn enum_options(kind: Kind) -> Vec<FieldOption> {
match kind {
Kind::Enum(enum_desc) => enum_desc
.values()
.map(|value| FieldOption {
label: value.name().to_string(),
value: value.number().to_string(),
})
.collect(),
_ => Vec::new(),
}
}
fn should_expose_field(field: &FieldDescriptor) -> bool {
match field.containing_oneof() {
Some(_) => field
.field_descriptor_proto()
.proto3_optional
.unwrap_or(false),
None => true,
}
}
fn build_validations(field: &FieldDescriptor) -> Vec<ValidationRule> {
if field.cardinality() == Cardinality::Required {
return vec![ValidationRule {
rule_type: "required".to_string(),
arg: String::new(),
message: format!("{} is required", field.name()),
}];
}
Vec::new()
}
fn kind_to_value_kind(field: &FieldDescriptor) -> String {
if field.is_map() {
return "object".to_string();
}
match field.kind() {
Kind::Bool => "boolean".to_string(),
Kind::String | Kind::Bytes => "string".to_string(),
Kind::Int32
| Kind::Sint32
| Kind::Sfixed32
| Kind::Int64
| Kind::Sint64
| Kind::Sfixed64
| Kind::Uint32
| Kind::Fixed32
| Kind::Uint64
| Kind::Fixed64
| Kind::Float
| Kind::Double => "number".to_string(),
Kind::Enum(_) => "enum".to_string(),
Kind::Message(_) => "object".to_string(),
}
}
fn build_node(
node_kind: &str,
name: String,
field_number: i32,
type_name: Option<String>,
semantic_type: Option<String>,
value_kind: String,
is_list: bool,
required: bool,
default_value_text: Option<String>,
enum_options: Vec<FieldOption>,
validations: Vec<ValidationRule>,
children: Vec<NetworkConfigSchema>,
definitions: Vec<NetworkConfigSchema>,
) -> NetworkConfigSchema {
NetworkConfigSchema {
node_kind: node_kind.to_string(),
name,
field_number,
type_name,
semantic_type,
value_kind,
is_list,
required,
default_value_text,
enum_options,
validations,
children,
definitions,
}
}
fn build_map_entry_node(message_desc: &MessageDescriptor) -> NetworkConfigSchema {
let key_field = message_desc.map_entry_key_field();
let value_field = message_desc.map_entry_value_field();
build_node(
"object",
message_desc.name().to_string(),
0,
Some(message_desc.full_name().to_string()),
None,
"object".to_string(),
false,
true,
None,
Vec::new(),
Vec::new(),
vec![
build_schema_field_node(&key_field),
build_schema_field_node(&value_field),
],
Vec::new(),
)
}
fn field_children(field: &FieldDescriptor) -> Vec<NetworkConfigSchema> {
if field.is_map() {
if let Kind::Message(message_desc) = field.kind() {
return vec![build_map_entry_node(&message_desc)];
}
}
match field.kind() {
Kind::Message(message_desc) => build_message_children(&message_desc),
_ => Vec::new(),
}
}
fn build_message_children(message_desc: &MessageDescriptor) -> Vec<NetworkConfigSchema> {
message_desc
.fields()
.filter(should_expose_field)
.map(|field| build_schema_field_node(&field))
.collect()
}
fn build_schema_field_node(field: &FieldDescriptor) -> NetworkConfigSchema {
build_node(
"field",
field.name().to_string(),
field.number() as i32,
field_type_name(field),
field_semantic_type(field),
kind_to_value_kind(field),
field.is_list() || field.is_map(),
field.cardinality() == Cardinality::Required,
field_default_value_text(field),
enum_options(field.kind()),
build_validations(field),
field_children(field),
Vec::new(),
)
}
fn collect_definitions() -> Vec<NetworkConfigSchema> {
let mut definitions = Vec::new();
for message_desc in descriptor_pool().all_messages() {
let full_name = message_desc.full_name();
if full_name == NETWORK_CONFIG_MESSAGE_NAME || message_desc.is_map_entry() {
continue;
}
definitions.push(build_node(
"object",
full_name.to_string(),
0,
Some(full_name.to_string()),
None,
"object".to_string(),
false,
true,
None,
Vec::new(),
Vec::new(),
build_message_children(&message_desc),
Vec::new(),
));
}
for enum_desc in descriptor_pool().all_enums() {
definitions.push(build_node(
"enum",
enum_desc.full_name().to_string(),
0,
Some(enum_desc.full_name().to_string()),
None,
"enum".to_string(),
false,
false,
None,
enum_options(Kind::Enum(enum_desc.clone())),
Vec::new(),
Vec::new(),
Vec::new(),
));
}
definitions.sort_by(|a, b| a.name.cmp(&b.name));
definitions
}
fn build_network_config_schema() -> NetworkConfigSchema {
let network_config = network_config_descriptor();
build_node(
"schema",
network_config.name().to_string(),
0,
Some(network_config.full_name().to_string()),
None,
"object".to_string(),
false,
true,
None,
Vec::new(),
Vec::new(),
build_message_children(&network_config),
collect_definitions(),
)
}
fn build_network_config_field_mappings() -> Vec<ConfigFieldMapping> {
network_config_descriptor()
.fields()
.filter(should_expose_field)
.map(|field| ConfigFieldMapping {
field_name: field.name().to_string(),
field_number: field.number() as i32,
})
.collect()
}
pub fn get_network_config_schema() -> NetworkConfigSchema {
build_network_config_schema()
}
pub fn get_network_config_field_mappings() -> Vec<ConfigFieldMapping> {
build_network_config_field_mappings()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schema_is_exposed_as_single_tree_type() {
let schema = get_network_config_schema();
assert_eq!(schema.node_kind, "schema");
assert_eq!(schema.name, "NetworkConfig");
assert_eq!(
schema.type_name.as_deref(),
Some("api.manage.NetworkConfig")
);
let virtual_ipv4 = schema
.children
.iter()
.find(|field| field.name == "virtual_ipv4")
.expect("virtual_ipv4 field");
assert_eq!(virtual_ipv4.semantic_type.as_deref(), Some("cidr_ip"));
let secure_mode = schema
.children
.iter()
.find(|field| field.name == "secure_mode")
.expect("secure_mode field");
assert!(
secure_mode
.children
.iter()
.any(|field| field.name == "enabled")
);
let secure_mode_definition = schema
.definitions
.iter()
.find(|definition| definition.name == "common.SecureModeConfig")
.expect("secure mode definition");
assert!(
secure_mode_definition
.children
.iter()
.any(|field| field.name == "local_private_key")
);
let networking_method_definition = schema
.definitions
.iter()
.find(|definition| definition.name == "api.manage.NetworkingMethod")
.expect("networking method enum definition");
assert!(
networking_method_definition
.enum_options
.iter()
.any(|option| option.label == "PublicServer")
);
}
}
@@ -0,0 +1,197 @@
use crate::config::repository::{get_config_record, save_config_record};
use crate::config::services::schema_service::get_network_config_field_mappings;
use crate::config::types::stored_config::SharedConfigLinkPayload;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use easytier::proto::api::manage::NetworkConfig;
use flate2::{Compression, read::ZlibDecoder, write::ZlibEncoder};
use gethostname::gethostname;
use std::collections::HashMap;
use std::io::{Read, Write};
use url::Url;
use uuid::Uuid;
const SHARE_LINK_HOST: &str = "easytier.cn";
const SHARE_LINK_PATH: &str = "/comp_cfg";
fn field_name_to_id_map() -> HashMap<String, String> {
get_network_config_field_mappings()
.into_iter()
.map(|mapping| (mapping.field_name, mapping.field_number.to_string()))
.collect()
}
fn field_id_to_name_map() -> HashMap<String, String> {
get_network_config_field_mappings()
.into_iter()
.map(|mapping| (mapping.field_number.to_string(), mapping.field_name))
.collect()
}
fn prune_empty(value: &serde_json::Value) -> Option<serde_json::Value> {
match value {
serde_json::Value::Null => None,
serde_json::Value::Array(values) if values.is_empty() => None,
_ => Some(value.clone()),
}
}
fn map_config_json(config: &NetworkConfig) -> Result<String, String> {
let field_name_to_id = field_name_to_id_map();
let raw = serde_json::to_value(config).map_err(|err| err.to_string())?;
let mut mapped = serde_json::Map::new();
for (key, value) in raw.as_object().cloned().unwrap_or_default() {
let Some(value) = prune_empty(&value) else {
continue;
};
let mapped_key = field_name_to_id.get(&key).cloned().unwrap_or(key);
mapped.insert(mapped_key, value);
}
serde_json::to_string(&mapped).map_err(|err| err.to_string())
}
fn unmap_config_json(raw: &str) -> Result<NetworkConfig, String> {
let field_id_to_name = field_id_to_name_map();
let value = serde_json::from_str::<serde_json::Value>(raw).map_err(|err| err.to_string())?;
let mut mapped = serde_json::Map::new();
for (key, value) in value.as_object().cloned().unwrap_or_default() {
let field_name = field_id_to_name.get(&key).cloned().unwrap_or(key);
mapped.insert(field_name, value);
}
serde_json::from_value(serde_json::Value::Object(mapped)).map_err(|err| err.to_string())
}
fn compress_to_base64url(raw: &str) -> Result<String, String> {
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::best());
encoder
.write_all(raw.as_bytes())
.map_err(|err| err.to_string())?;
let compressed = encoder.finish().map_err(|err| err.to_string())?;
Ok(URL_SAFE_NO_PAD.encode(compressed))
}
fn decompress_from_base64url(raw: &str) -> Result<String, String> {
let compressed = URL_SAFE_NO_PAD.decode(raw).map_err(|err| err.to_string())?;
let mut decoder = ZlibDecoder::new(compressed.as_slice());
let mut out = String::new();
decoder
.read_to_string(&mut out)
.map_err(|err| err.to_string())?;
Ok(out)
}
pub fn build_config_share_link(
config_id: &str,
display_name: Option<String>,
only_start: bool,
) -> Option<String> {
let record = get_config_record(config_id)?;
let config = serde_json::from_str::<NetworkConfig>(&record.config_json).ok()?;
let mapped_json = map_config_json(&config).ok()?;
let compressed = compress_to_base64url(&mapped_json).ok()?;
let final_name = display_name
.or(Some(record.meta.display_name))
.filter(|name| !name.is_empty());
let mut url = Url::parse(&format!("https://{SHARE_LINK_HOST}{SHARE_LINK_PATH}")).ok()?;
url.query_pairs_mut().append_pair("cfg", &compressed);
if let Some(name) = final_name {
url.query_pairs_mut().append_pair("name", &name);
}
if only_start {
url.query_pairs_mut().append_pair("only_start", "true");
}
Some(url.to_string())
}
pub fn parse_config_share_link(share_link: &str) -> Option<SharedConfigLinkPayload> {
let url = Url::parse(share_link).ok()?;
if url.host_str()? != SHARE_LINK_HOST || url.path() != SHARE_LINK_PATH {
return None;
}
let cfg = url
.query_pairs()
.find(|(key, _)| key == "cfg")?
.1
.to_string();
let mapped_json = decompress_from_base64url(&cfg).ok()?;
let mut config = unmap_config_json(&mapped_json).ok()?;
config.instance_id = Some(Uuid::new_v4().to_string());
let hostname = gethostname().to_string_lossy().to_string();
if !hostname.is_empty() {
config.hostname = Some(hostname);
}
let config_json = serde_json::to_string(&config).ok()?;
let display_name = url
.query_pairs()
.find(|(key, _)| key == "name")
.map(|(_, value)| value.to_string())
.filter(|name| !name.is_empty());
let only_start = url
.query_pairs()
.find(|(key, _)| key == "only_start")
.map(|(_, value)| value == "true")
.unwrap_or(false);
Some(SharedConfigLinkPayload {
config_json,
display_name,
only_start,
})
}
pub fn import_config_share_link(
share_link: &str,
display_name_override: Option<String>,
) -> Option<String> {
let payload = parse_config_share_link(share_link)?;
let config = serde_json::from_str::<NetworkConfig>(&payload.config_json).ok()?;
let config_id = config.instance_id.clone()?;
let display_name = display_name_override
.filter(|name| !name.is_empty())
.or(payload.display_name)
.unwrap_or_else(|| config_id.clone());
save_config_record(config_id.clone(), display_name, payload.config_json)?;
Some(config_id)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config_repo::{create_config_record, init_config_store};
use std::time::{SystemTime, UNIX_EPOCH};
fn test_root() -> String {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir()
.join(format!("easytier_ohrs_share_test_{unique}"))
.to_string_lossy()
.into_owned()
}
#[test]
fn share_link_roundtrip_works() {
assert!(init_config_store(test_root()));
create_config_record("cfg-share".to_string(), "share-demo".to_string())
.expect("create config");
let link = build_config_share_link("cfg-share", None, true).expect("share link");
let payload = parse_config_share_link(&link).expect("parse link");
let config =
serde_json::from_str::<NetworkConfig>(&payload.config_json).expect("config json");
assert!(payload.only_start);
assert_eq!(payload.display_name.as_deref(), Some("share-demo"));
assert_ne!(config.instance_id.as_deref(), Some("cfg-share"));
let imported_id = import_config_share_link(&link, None).expect("import link");
assert_ne!(imported_id, "cfg-share");
}
}
@@ -0,0 +1,333 @@
use crate::config::types::stored_config::{StoredConfigList, StoredConfigMeta};
use ohos_hilog_binding::{hilog_debug, hilog_error};
use rusqlite::{Connection, OptionalExtension, params};
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
static CONFIG_DB_PATH: Mutex<Option<PathBuf>> = Mutex::new(None);
const CONFIG_DB_FILE_NAME: &str = "easytier-config-store.db";
#[derive(Debug, Clone)]
struct StoredConfigMetaRecord {
config_id: String,
display_name: String,
created_at: String,
updated_at: String,
favorite: bool,
temporary: bool,
}
pub(crate) fn now_ts_string() -> String {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs().to_string())
.unwrap_or_else(|_| "0".to_string())
}
fn db_file_path() -> Option<PathBuf> {
CONFIG_DB_PATH
.lock()
.ok()
.and_then(|guard| guard.as_ref().cloned())
}
fn init_schema(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch(
"PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS stored_configs (
config_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
favorite INTEGER NOT NULL DEFAULT 0,
temporary INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS stored_config_fields (
config_id TEXT NOT NULL,
field_name TEXT NOT NULL,
field_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (config_id, field_name),
FOREIGN KEY (config_id) REFERENCES stored_configs(config_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_stored_config_fields_config_id
ON stored_config_fields(config_id);",
)
}
pub(crate) fn open_db() -> Option<Connection> {
let path = db_file_path()?;
let conn = match Connection::open(&path) {
Ok(conn) => conn,
Err(e) => {
hilog_error!("[Rust] failed to open config db {}: {}", path.display(), e);
return None;
}
};
if let Err(e) = init_schema(&conn) {
hilog_error!(
"[Rust] failed to initialize config db {}: {}",
path.display(),
e
);
return None;
}
Some(conn)
}
fn row_to_meta(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoredConfigMetaRecord> {
Ok(StoredConfigMetaRecord {
config_id: row.get(0)?,
display_name: row.get(1)?,
created_at: row.get(2)?,
updated_at: row.get(3)?,
favorite: row.get::<_, i64>(4)? != 0,
temporary: row.get::<_, i64>(5)? != 0,
})
}
fn load_meta_record(conn: &Connection, config_id: &str) -> Option<StoredConfigMetaRecord> {
conn.query_row(
"SELECT config_id, display_name, created_at, updated_at, favorite, temporary
FROM stored_configs WHERE config_id = ?1",
params![config_id],
row_to_meta,
)
.optional()
.ok()
.flatten()
}
fn to_meta(record: StoredConfigMetaRecord) -> StoredConfigMeta {
StoredConfigMeta {
config_id: record.config_id,
display_name: record.display_name,
created_at: record.created_at,
updated_at: record.updated_at,
favorite: record.favorite,
temporary: record.temporary,
}
}
pub fn init_config_meta_store(root_dir: String) -> bool {
let root = PathBuf::from(root_dir);
if let Err(e) = std::fs::create_dir_all(&root) {
hilog_error!(
"[Rust] failed to create config db dir {}: {}",
root.display(),
e
);
return false;
}
let db_path = root.join(CONFIG_DB_FILE_NAME);
match CONFIG_DB_PATH.lock() {
Ok(mut guard) => {
*guard = Some(db_path.clone());
}
Err(e) => {
hilog_error!("[Rust] failed to lock config db path: {}", e);
return false;
}
}
if open_db().is_none() {
return false;
}
hilog_debug!("[Rust] initialized config db at {}", db_path.display());
true
}
pub fn list_config_meta_entries() -> StoredConfigList {
let Some(conn) = open_db() else {
return StoredConfigList { configs: vec![] };
};
let mut stmt = match conn.prepare(
"SELECT config_id, display_name, created_at, updated_at, favorite, temporary
FROM stored_configs
ORDER BY updated_at DESC, display_name ASC",
) {
Ok(stmt) => stmt,
Err(e) => {
hilog_error!("[Rust] failed to prepare list meta query: {}", e);
return StoredConfigList { configs: vec![] };
}
};
let rows = match stmt.query_map([], row_to_meta) {
Ok(rows) => rows,
Err(e) => {
hilog_error!("[Rust] failed to list config meta rows: {}", e);
return StoredConfigList { configs: vec![] };
}
};
let configs = rows.filter_map(Result::ok).map(to_meta).collect();
StoredConfigList { configs }
}
pub fn get_config_display_name(config_id: &str) -> Option<String> {
let conn = open_db()?;
load_meta_record(&conn, config_id).map(|record| record.display_name)
}
pub fn get_config_meta(config_id: &str) -> Option<StoredConfigMeta> {
let conn = open_db()?;
load_meta_record(&conn, config_id).map(to_meta)
}
pub fn upsert_config_meta(
config_id: String,
display_name: String,
favorite: bool,
temporary: bool,
) -> StoredConfigMeta {
let now = now_ts_string();
let Some(conn) = open_db() else {
return StoredConfigMeta {
config_id,
display_name,
created_at: now.clone(),
updated_at: now,
favorite,
temporary,
};
};
let created_at = load_meta_record(&conn, &config_id)
.map(|record| record.created_at)
.unwrap_or_else(|| now.clone());
if let Err(e) = conn.execute(
"INSERT INTO stored_configs (
config_id, display_name, created_at, updated_at, favorite, temporary
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(config_id) DO UPDATE SET
display_name = excluded.display_name,
updated_at = excluded.updated_at,
favorite = excluded.favorite,
temporary = excluded.temporary",
params![
config_id,
display_name,
created_at,
now,
if favorite { 1 } else { 0 },
if temporary { 1 } else { 0 }
],
) {
hilog_error!("[Rust] failed to upsert config meta: {}", e);
}
get_config_meta(&config_id).unwrap_or(StoredConfigMeta {
config_id,
display_name,
created_at,
updated_at: now,
favorite,
temporary,
})
}
pub(crate) fn upsert_config_meta_in_tx(
tx: &rusqlite::Transaction<'_>,
config_id: String,
display_name: String,
favorite: bool,
temporary: bool,
) -> Option<StoredConfigMeta> {
let now = now_ts_string();
let created_at = tx
.query_row(
"SELECT config_id, display_name, created_at, updated_at, favorite, temporary
FROM stored_configs WHERE config_id = ?1",
params![config_id],
row_to_meta,
)
.optional()
.ok()
.flatten()
.map(|record| record.created_at)
.unwrap_or_else(|| now.clone());
tx.execute(
"INSERT INTO stored_configs (
config_id, display_name, created_at, updated_at, favorite, temporary
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(config_id) DO UPDATE SET
display_name = excluded.display_name,
updated_at = excluded.updated_at,
favorite = excluded.favorite,
temporary = excluded.temporary",
params![
config_id,
display_name,
created_at,
now,
if favorite { 1 } else { 0 },
if temporary { 1 } else { 0 }
],
)
.ok()?;
tx.query_row(
"SELECT config_id, display_name, created_at, updated_at, favorite, temporary
FROM stored_configs WHERE config_id = ?1",
params![config_id],
row_to_meta,
)
.optional()
.ok()
.flatten()
.map(to_meta)
.or(Some(StoredConfigMeta {
config_id,
display_name,
created_at,
updated_at: now,
favorite,
temporary,
}))
}
pub fn set_config_display_name(
config_id: String,
display_name: String,
) -> Option<StoredConfigMeta> {
let conn = open_db()?;
let mut record = load_meta_record(&conn, &config_id)?;
record.display_name = display_name;
record.updated_at = now_ts_string();
conn.execute(
"UPDATE stored_configs
SET display_name = ?2, updated_at = ?3
WHERE config_id = ?1",
params![config_id, record.display_name, record.updated_at],
)
.ok()?;
Some(to_meta(record))
}
pub fn delete_config_meta(config_id: &str) -> bool {
let Some(conn) = open_db() else {
return false;
};
match conn.execute(
"DELETE FROM stored_configs WHERE config_id = ?1",
params![config_id],
) {
Ok(rows) => rows > 0,
Err(e) => {
hilog_error!("[Rust] failed to delete config meta {}: {}", config_id, e);
false
}
}
}
@@ -0,0 +1 @@
pub(crate) mod config_meta;
@@ -0,0 +1 @@
pub(crate) mod stored_config;
@@ -0,0 +1,68 @@
use napi_derive_ohos::napi;
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
#[napi(object)]
pub struct StoredConfigMeta {
pub config_id: String,
pub display_name: String,
pub created_at: String,
pub updated_at: String,
pub favorite: bool,
pub temporary: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
#[napi(object)]
pub struct StoredConfigRecord {
pub meta: StoredConfigMeta,
pub config_json: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
#[napi(object)]
pub struct StoredConfigList {
pub configs: Vec<StoredConfigMeta>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
#[napi(object)]
pub struct ExportTomlResult {
pub toml_text: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
#[napi(object)]
pub struct StoredConfigSummary {
pub config_id: String,
pub display_name: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
#[napi(object)]
pub struct SharedConfigLinkPayload {
pub config_json: String,
pub display_name: Option<String>,
pub only_start: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
#[napi(object)]
pub struct LocalSocketSyncMessage {
pub message_type: String,
pub payload_json: String,
}
#[derive(Debug, Clone, Serialize)]
#[napi(object)]
pub struct KeyValuePair {
pub key: String,
pub value: String,
}
@@ -0,0 +1,349 @@
use super::{field_store, import_export, legacy_migration, validation};
use crate::config::storage::config_meta::{
delete_config_meta, get_config_meta, init_config_meta_store, list_config_meta_entries, open_db,
upsert_config_meta_in_tx,
};
use crate::config::types::stored_config::{ExportTomlResult, StoredConfigRecord};
use easytier::common::config::ConfigLoader;
use easytier::proto::api::manage::NetworkConfig;
use ohos_hilog_binding::{hilog_debug, hilog_error};
use rusqlite::params;
use serde_json::Value;
use std::path::PathBuf;
use std::sync::Mutex;
static CONFIG_ROOT_DIR: Mutex<Option<PathBuf>> = Mutex::new(None);
pub(crate) const CONFIG_DIR_NAME: &str = "easytier-configs";
pub(crate) const KERNEL_SOCKET_FILE_NAME: &str = "easytier-kernel.sock";
pub(crate) fn config_root_dir() -> Option<PathBuf> {
CONFIG_ROOT_DIR
.lock()
.ok()
.and_then(|guard| guard.as_ref().cloned())
}
pub(crate) fn kernel_socket_path() -> Option<PathBuf> {
config_root_dir().map(|root| root.join(KERNEL_SOCKET_FILE_NAME))
}
pub(crate) fn legacy_config_file_path(config_id: &str) -> Option<PathBuf> {
legacy_migration::legacy_config_file_path(&config_root_dir(), CONFIG_DIR_NAME, config_id)
}
pub fn init_config_store(root_dir: String) -> bool {
let root = PathBuf::from(root_dir);
let configs_dir = root.join(CONFIG_DIR_NAME);
if let Err(e) = std::fs::create_dir_all(&configs_dir) {
hilog_error!(
"[Rust] failed to create config dir {}: {}",
configs_dir.display(),
e
);
return false;
}
match CONFIG_ROOT_DIR.lock() {
Ok(mut guard) => {
*guard = Some(root.clone());
}
Err(e) => {
hilog_error!("[Rust] failed to lock config root dir: {}", e);
return false;
}
}
if !init_config_meta_store(root.to_string_lossy().into_owned()) {
return false;
}
hilog_debug!(
"[Rust] initialized config repo at {}",
configs_dir.display()
);
true
}
fn migrate_legacy_file_if_needed(config_id: &str) -> Option<()> {
legacy_migration::migrate_legacy_file_if_needed(
&config_root_dir(),
CONFIG_DIR_NAME,
config_id,
save_config_record,
)
}
pub fn save_config_record(
config_id: String,
display_name: String,
config_json: String,
) -> Option<StoredConfigRecord> {
let config = match validation::validate_config_json(&config_json, config_id.clone()) {
Ok(config) => config,
Err(e) => {
hilog_error!("[Rust] save_config_record failed {}", e);
return None;
}
};
let normalized_json = match serde_json::to_string(&config) {
Ok(raw) => raw,
Err(e) => {
hilog_error!(
"[Rust] failed to serialize normalized config {}: {}",
config_id,
e
);
return None;
}
};
let fields = match validation::config_to_top_level_map(&config) {
Some(fields) => fields,
None => return None,
};
let conn = open_db()?;
let tx = conn.unchecked_transaction().ok()?;
let existing_meta = get_config_meta(&config_id);
let favorite = existing_meta
.as_ref()
.map(|meta| meta.favorite)
.unwrap_or(false);
let temporary = existing_meta
.as_ref()
.map(|meta| meta.temporary)
.unwrap_or(false);
let meta = upsert_config_meta_in_tx(&tx, config_id.clone(), display_name, favorite, temporary)?;
field_store::replace_config_fields(&tx, &config_id, fields)?;
tx.commit().ok()?;
if let Some(legacy_path) = legacy_config_file_path(&config_id) {
if legacy_path.exists() {
let _ = std::fs::remove_file(legacy_path);
}
}
Some(StoredConfigRecord {
meta,
config_json: normalized_json,
})
}
pub fn load_config_json(config_id: &str) -> Option<String> {
migrate_legacy_file_if_needed(config_id)?;
let object = field_store::load_config_map_from_db(config_id)?;
serde_json::to_string(&Value::Object(object)).ok()
}
pub fn get_config_record(config_id: &str) -> Option<StoredConfigRecord> {
let config_json = load_config_json(config_id)?;
let meta = get_config_meta(config_id)?;
Some(StoredConfigRecord { meta, config_json })
}
pub fn get_config_field_value(config_id: &str, field: &str) -> Option<String> {
migrate_legacy_file_if_needed(config_id)?;
let conn = open_db()?;
conn.query_row(
"SELECT field_json FROM stored_config_fields
WHERE config_id = ?1 AND field_name = ?2",
params![config_id, field],
|row| row.get::<_, String>(0),
)
.ok()
}
pub fn set_config_field_value(config_id: &str, field: &str, json_value: &str) -> bool {
if field.contains('.') {
return false;
}
let raw = match load_config_json(config_id) {
Some(raw) => raw,
None => return false,
};
let mut value = match serde_json::from_str::<Value>(&raw) {
Ok(value) => value,
Err(_) => return false,
};
let new_field_value = match serde_json::from_str::<Value>(json_value) {
Ok(value) => value,
Err(_) => return false,
};
let object = match value.as_object_mut() {
Some(object) => object,
None => return false,
};
object.insert(field.to_string(), new_field_value);
let normalized = match serde_json::to_string(&value) {
Ok(raw) => raw,
Err(_) => return false,
};
let display_name = get_config_meta(config_id)
.map(|meta| meta.display_name)
.unwrap_or_else(|| config_id.to_string());
save_config_record(config_id.to_string(), display_name, normalized).is_some()
}
pub fn get_display_name(config_id: &str) -> Option<String> {
get_config_meta(config_id).map(|meta| meta.display_name)
}
pub fn get_default_config_json() -> Option<String> {
crate::build_default_network_config_json().ok()
}
pub fn create_config_record(config_id: String, display_name: String) -> Option<StoredConfigRecord> {
let raw = get_default_config_json()?;
let mut config = serde_json::from_str::<NetworkConfig>(&raw).ok()?;
config.instance_id = Some(config_id.clone());
let normalized_json = serde_json::to_string(&config).ok()?;
save_config_record(config_id, display_name, normalized_json)
}
pub fn start_kernel_with_config_id(config_id: &str) -> bool {
let raw = match load_config_json(config_id) {
Some(raw) => raw,
None => return false,
};
crate::run_network_instance_from_json(&raw)
}
pub fn list_config_meta_json() -> String {
serde_json::to_string(&list_config_meta_entries().configs).unwrap_or_else(|_| "[]".to_string())
}
pub fn delete_config_record(config_id: &str) -> bool {
if let Some(path) = legacy_config_file_path(config_id) {
if path.exists() {
let _ = std::fs::remove_file(path);
}
}
let conn = match open_db() {
Some(conn) => conn,
None => return false,
};
if let Err(e) = conn.execute(
"DELETE FROM stored_config_fields WHERE config_id = ?1",
params![config_id],
) {
hilog_error!("[Rust] failed to delete config fields {}: {}", config_id, e);
return false;
}
delete_config_meta(config_id)
}
pub fn export_config_toml(config_id: &str) -> Option<ExportTomlResult> {
let record = get_config_record(config_id)?;
import_export::export_config_toml_from_record(&record)
}
pub fn import_toml_config(
toml_text: String,
display_name: Option<String>,
) -> Option<StoredConfigRecord> {
import_export::import_toml_to_record(toml_text, display_name, save_config_record)
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::params;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
fn test_root() -> String {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("easytier_ohrs_test_{}", unique));
dir.to_string_lossy().into_owned()
}
#[test]
fn save_get_export_delete_roundtrip() {
let root = test_root();
assert!(init_config_store(root.clone()));
let config_json = crate::build_default_network_config_json().expect("default config");
let saved = save_config_record("cfg-1".to_string(), "test-config".to_string(), config_json)
.expect("save config");
assert_eq!(saved.meta.config_id, "cfg-1");
assert_eq!(saved.meta.display_name, "test-config");
let loaded = get_config_record("cfg-1").expect("load config");
assert_eq!(loaded.meta.display_name, "test-config");
assert!(loaded.config_json.contains("cfg-1"));
let legacy_json_path = PathBuf::from(&root)
.join(CONFIG_DIR_NAME)
.join("cfg-1.json");
assert!(
!legacy_json_path.exists(),
"config should no longer be persisted as a per-config json file"
);
let conn = open_db().expect("db should be open");
let field_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM stored_config_fields WHERE config_id = ?1",
params!["cfg-1"],
|row| row.get(0),
)
.expect("count config fields");
assert!(field_count > 0, "config fields should be stored in sqlite");
let exported = export_config_toml("cfg-1").expect("export toml");
assert!(exported.toml_text.contains("instance_id"));
assert!(delete_config_record("cfg-1"));
assert!(get_config_record("cfg-1").is_none());
}
#[test]
fn set_config_field_updates_only_requested_top_level_field() {
let root = test_root();
assert!(init_config_store(root));
let config_json = crate::build_default_network_config_json().expect("default config");
save_config_record(
"cfg-field".to_string(),
"field-config".to_string(),
config_json,
)
.expect("save config");
let before_network_name = get_config_field_value("cfg-field", "network_name");
let before_instance_id = get_config_field_value("cfg-field", "instance_id")
.expect("instance id field should exist");
assert!(set_config_field_value(
"cfg-field",
"network_name",
"\"changed-network\""
));
assert_eq!(
get_config_field_value("cfg-field", "network_name"),
Some("\"changed-network\"".to_string())
);
assert_eq!(
get_config_field_value("cfg-field", "instance_id"),
Some(before_instance_id)
);
assert_ne!(
get_config_field_value("cfg-field", "network_name"),
before_network_name
);
}
}
@@ -0,0 +1,67 @@
use crate::config::storage::config_meta::{now_ts_string, open_db};
use ohos_hilog_binding::hilog_error;
use rusqlite::{Connection, params};
use serde_json::{Map, Value};
pub(super) fn load_config_map_from_db(config_id: &str) -> Option<Map<String, Value>> {
let conn = open_db()?;
let mut stmt = conn
.prepare(
"SELECT field_name, field_json
FROM stored_config_fields
WHERE config_id = ?1",
)
.ok()?;
let rows = stmt
.query_map(params![config_id], |row| {
let field_name: String = row.get(0)?;
let field_json: String = row.get(1)?;
Ok((field_name, field_json))
})
.ok()?;
let mut object = Map::new();
for row in rows {
let (field_name, field_json) = row.ok()?;
let value = serde_json::from_str::<Value>(&field_json).ok()?;
object.insert(field_name, value);
}
if object.is_empty() {
None
} else {
Some(object)
}
}
pub(super) fn replace_config_fields(
tx: &Connection,
config_id: &str,
fields: Map<String, Value>,
) -> Option<()> {
if let Err(e) = tx.execute(
"DELETE FROM stored_config_fields WHERE config_id = ?1",
params![config_id],
) {
hilog_error!(
"[Rust] failed to clear existing config fields {}: {}",
config_id,
e
);
return None;
}
for (field_name, value) in fields {
let field_json = serde_json::to_string(&value).ok()?;
if let Err(e) = tx.execute(
"INSERT INTO stored_config_fields (config_id, field_name, field_json, updated_at)
VALUES (?1, ?2, ?3, ?4)",
params![config_id, field_name, field_json, now_ts_string()],
) {
hilog_error!("[Rust] failed to persist config field {}: {}", config_id, e);
return None;
}
}
Some(())
}
@@ -0,0 +1,48 @@
use crate::config::types::stored_config::{ExportTomlResult, StoredConfigRecord};
use easytier::common::config::{ConfigLoader, TomlConfigLoader};
use easytier::proto::api::manage::NetworkConfig;
pub(super) fn export_config_toml_from_record(
record: &StoredConfigRecord,
) -> Option<ExportTomlResult> {
let config = serde_json::from_str::<NetworkConfig>(&record.config_json).ok()?;
let toml = config.gen_config().ok()?;
Some(ExportTomlResult {
toml_text: toml.dump(),
})
}
pub(super) fn import_toml_to_record(
toml_text: String,
display_name: Option<String>,
save_config_record: impl Fn(String, String, String) -> Option<StoredConfigRecord>,
) -> Option<StoredConfigRecord> {
let config =
NetworkConfig::new_from_config(TomlConfigLoader::new_from_str(&toml_text).ok()?).ok()?;
let config_id = config.instance_id.clone()?;
let name_from_toml = toml_text
.lines()
.find_map(|line| {
let trimmed = line.trim();
if !trimmed.starts_with("instance_name") {
return None;
}
trimmed.split_once('=').map(|(_, value)| {
value
.trim()
.trim_matches('"')
.trim_matches('\'')
.to_string()
})
})
.filter(|name| !name.is_empty());
let final_name = display_name
.filter(|name| !name.is_empty())
.or(name_from_toml)
.unwrap_or_else(|| config_id.clone());
let config_json = serde_json::to_string(&config).ok()?;
save_config_record(config_id, final_name, config_json)
}
@@ -0,0 +1,45 @@
use crate::config::storage::config_meta::get_config_meta;
use ohos_hilog_binding::hilog_error;
use std::path::PathBuf;
pub(super) fn legacy_config_file_path(
root_dir: &Option<PathBuf>,
config_dir_name: &str,
config_id: &str,
) -> Option<PathBuf> {
root_dir.as_ref().map(|root| {
root.join(config_dir_name)
.join(format!("{}.json", config_id))
})
}
pub(super) fn migrate_legacy_file_if_needed(
root_dir: &Option<PathBuf>,
config_dir_name: &str,
config_id: &str,
save_config_record: impl Fn(
String,
String,
String,
) -> Option<crate::config::types::stored_config::StoredConfigRecord>,
) -> Option<()> {
let legacy_path = legacy_config_file_path(root_dir, config_dir_name, config_id)?;
if !legacy_path.exists() {
return Some(());
}
let raw = std::fs::read_to_string(&legacy_path).ok()?;
let display_name = get_config_meta(config_id)
.map(|meta| meta.display_name)
.unwrap_or_else(|| config_id.to_string());
save_config_record(config_id.to_string(), display_name, raw)?;
if let Err(e) = std::fs::remove_file(&legacy_path) {
hilog_error!(
"[Rust] failed to remove legacy config file {}: {}",
legacy_path.display(),
e
);
}
Some(())
}
@@ -0,0 +1,30 @@
use easytier::proto::api::manage::NetworkConfig;
use serde_json::{Map, Value};
pub(super) fn normalize_config_id(
mut config: NetworkConfig,
requested_id: String,
) -> Result<NetworkConfig, String> {
if requested_id.is_empty() {
return Err("config_id is required".to_string());
}
config.instance_id = Some(requested_id);
Ok(config)
}
pub(super) fn validate_config_json(
config_json: &str,
config_id: String,
) -> Result<NetworkConfig, String> {
let config = serde_json::from_str::<NetworkConfig>(config_json)
.map_err(|e| format!("parse config json failed: {}", e))?;
let config = normalize_config_id(config, config_id)?;
config
.gen_config()
.map_err(|e| format!("generate toml failed: {}", e))?;
Ok(config)
}
pub(super) fn config_to_top_level_map(config: &NetworkConfig) -> Option<Map<String, Value>> {
serde_json::to_value(config).ok()?.as_object().cloned()
}
@@ -0,0 +1,2 @@
pub(crate) mod config_api;
pub(crate) mod runtime_api;
@@ -0,0 +1,46 @@
use crate::config;
pub(crate) fn init_config_store(root_dir: String) -> bool {
config::repository::init_config_store(root_dir)
}
pub(crate) fn list_configs() -> String {
config::repository::list_config_meta_json()
}
pub(crate) fn save_config(config_id: String, display_name: String, config_json: String) -> bool {
config::repository::save_config_record(config_id, display_name, config_json).is_some()
}
pub(crate) fn create_config(config_id: String, display_name: String) -> bool {
config::repository::create_config_record(config_id, display_name).is_some()
}
pub(crate) fn delete_stored_config_meta(config_id: String) -> bool {
config::repository::delete_config_record(&config_id)
}
pub(crate) fn get_config(config_id: String) -> Option<String> {
config::repository::load_config_json(&config_id)
}
pub(crate) fn get_default_config() -> Option<String> {
config::repository::get_default_config_json()
}
pub(crate) fn get_config_field(config_id: String, field: String) -> Option<String> {
config::repository::get_config_field_value(&config_id, &field)
}
pub(crate) fn set_config_field(config_id: String, field: String, json_value: String) -> bool {
config::repository::set_config_field_value(&config_id, &field, &json_value)
}
pub(crate) fn import_toml(toml_text: String, display_name: Option<String>) -> Option<String> {
config::repository::import_toml_config(toml_text, display_name)
.map(|record| record.meta.config_id)
}
pub(crate) fn export_toml(config_id: String) -> Option<String> {
config::repository::export_config_toml(&config_id).map(|ret| ret.toml_text)
}
@@ -0,0 +1,184 @@
use crate::config::repository::load_config_json;
use crate::config::storage::config_meta::get_config_display_name;
use crate::config::types::stored_config::KeyValuePair;
use crate::kernel_bridge::{
aggregate_requested_tun_routes, start_local_socket_server as start_local_socket_server_inner,
stop_local_socket_server as stop_local_socket_server_inner,
};
use crate::runtime::state::runtime_state::{
RuntimeAggregateState, TunAggregateState, clear_tun_attached, mark_tun_attached,
runtime_instance_from_running_info,
};
use crate::{ASYNC_RUNTIME, EASYTIER_VERSION, INSTANCE_MANAGER, WEB_CLIENTS};
use easytier::proto::api::manage::NetworkConfig;
use ohos_hilog_binding::{hilog_error, hilog_info};
use std::sync::Arc;
pub(crate) fn start_kernel(
config_id: String,
start_kernel_with_config_id: impl Fn(&str) -> bool,
) -> bool {
start_kernel_with_config_id(&config_id)
}
pub(crate) fn stop_kernel(
config_id: String,
stop_web_client: impl Fn(&str) -> bool,
parse_instance_uuid: impl Fn(&str) -> Option<uuid::Uuid>,
maybe_stop_local_socket_server: impl Fn(),
) -> bool {
clear_tun_attached(&config_id);
if stop_web_client(&config_id) {
return true;
}
let Some(instance_id) = parse_instance_uuid(&config_id) else {
return false;
};
let ret = INSTANCE_MANAGER
.delete_network_instance(vec![instance_id])
.map(|_| true)
.unwrap_or_else(|err| {
hilog_error!("[Rust] stop_kernel failed {}: {}", config_id, err);
false
});
maybe_stop_local_socket_server();
ret
}
pub(crate) fn stop_network_instance(
config_ids: Vec<String>,
stop_kernel: impl Fn(String) -> bool,
) -> bool {
let mut ok = true;
for config_id in config_ids {
ok = stop_kernel(config_id) && ok;
}
ok
}
pub(crate) fn collect_network_infos() -> Vec<KeyValuePair> {
let infos = match INSTANCE_MANAGER.collect_network_infos_sync() {
Ok(infos) => infos,
Err(err) => {
hilog_error!("[Rust] collect network infos failed {}", err);
return vec![];
}
};
infos
.into_iter()
.filter_map(|(key, value)| {
serde_json::to_string(&value)
.ok()
.map(|value_json| KeyValuePair {
key: key.to_string(),
value: value_json,
})
})
.collect()
}
pub(crate) fn set_tun_fd(
config_id: String,
fd: i32,
parse_instance_uuid: impl Fn(&str) -> Option<uuid::Uuid>,
) -> bool {
let Some(instance_id) = parse_instance_uuid(&config_id) else {
hilog_error!("[Rust] set_tun_fd invalid instance id: {}", config_id);
return false;
};
INSTANCE_MANAGER
.set_tun_fd(&instance_id, fd)
.map(|_| {
mark_tun_attached(&config_id);
hilog_info!(
"[Rust] set_tun_fd success instance={} fd={} marked_attached=true",
config_id,
fd
);
true
})
.unwrap_or_else(|err| {
hilog_error!("[Rust] set_tun_fd failed {}: {}", config_id, err);
false
})
}
pub(crate) fn get_runtime_snapshot() -> RuntimeAggregateState {
get_runtime_snapshot_inner()
}
pub(crate) fn get_runtime_snapshot_inner() -> RuntimeAggregateState {
let infos = match INSTANCE_MANAGER.collect_network_infos_sync() {
Ok(infos) => infos,
Err(err) => {
hilog_error!("[Rust] collect network infos failed {}", err);
return RuntimeAggregateState {
instances: vec![],
tun: TunAggregateState {
active: false,
attached_instance_ids: vec![],
aggregated_routes: vec![],
dns_servers: vec![],
need_rebuild: false,
},
running_instance_count: 0,
};
}
};
let mut instances = Vec::with_capacity(infos.len());
for (instance_uuid, info) in infos {
let config_id = instance_uuid.to_string();
let display_name = get_config_display_name(&config_id).unwrap_or_else(|| config_id.clone());
let config_json = load_config_json(&config_id);
let stored_config = config_json
.as_deref()
.and_then(|raw| serde_json::from_str::<NetworkConfig>(raw).ok());
let magic_dns_enabled = stored_config
.as_ref()
.and_then(|cfg| cfg.enable_magic_dns)
.unwrap_or(false);
let need_exit_node = stored_config
.as_ref()
.map(|cfg| !cfg.exit_nodes.is_empty())
.unwrap_or(false);
instances.push(runtime_instance_from_running_info(
config_id,
display_name,
magic_dns_enabled,
need_exit_node,
info,
));
}
instances.sort_by(|a, b| {
a.display_name
.cmp(&b.display_name)
.then_with(|| a.instance_id.cmp(&b.instance_id))
});
let attached_instance_ids = instances
.iter()
.filter(|instance| instance.tun_required)
.map(|instance| instance.instance_id.clone())
.collect::<Vec<_>>();
let aggregated_routes = aggregate_requested_tun_routes(&instances);
let running_instance_count =
instances.iter().filter(|instance| instance.running).count() as i32;
let tun_active = !attached_instance_ids.is_empty();
RuntimeAggregateState {
instances,
tun: TunAggregateState {
active: tun_active,
attached_instance_ids,
aggregated_routes,
dns_servers: vec![],
need_rebuild: false,
},
running_instance_count,
}
}
@@ -0,0 +1,6 @@
mod protocol;
mod routing;
mod socket_server;
pub(crate) use routing::aggregate_requested_tun_routes;
pub use socket_server::{start_local_socket_server, stop_local_socket_server};
@@ -0,0 +1,50 @@
use crate::config::types::stored_config::LocalSocketSyncMessage;
use serde::Serialize;
use std::io::{Error, ErrorKind, Write};
use std::os::unix::net::UnixStream;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct TunRequestPayload {
pub config_id: String,
pub instance_id: String,
pub display_name: String,
pub virtual_ipv4: Option<String>,
pub virtual_ipv4_cidr: Option<String>,
pub aggregated_routes: Vec<String>,
pub magic_dns_enabled: bool,
pub need_exit_node: bool,
}
pub(crate) fn send_local_socket_message(
stream: &mut UnixStream,
message_type: &str,
payload_json: String,
) -> std::io::Result<()> {
let message = LocalSocketSyncMessage {
message_type: message_type.to_string(),
payload_json,
};
let mut raw = serde_json::to_vec(&message)
.map_err(|err| Error::new(ErrorKind::InvalidData, err.to_string()))?;
raw.push(b'\n');
stream.write_all(&raw)?;
Ok(())
}
pub(crate) fn broadcast_local_socket_message(
clients: &mut Vec<UnixStream>,
message_type: &str,
payload_json: &str,
) -> bool {
let mut active_clients = Vec::with_capacity(clients.len());
let mut delivered = false;
for mut client in clients.drain(..) {
if send_local_socket_message(&mut client, message_type, payload_json.to_string()).is_ok() {
delivered = true;
active_clients.push(client);
}
}
*clients = active_clients;
delivered
}
@@ -0,0 +1,105 @@
use crate::config::repository::load_config_json;
use crate::runtime::state::runtime_state::RuntimeInstanceState;
use easytier::proto::api::manage::NetworkConfig;
use ipnet::IpNet;
use ohos_hilog_binding::hilog_debug;
use std::collections::HashSet;
use std::net::IpAddr;
pub(crate) fn load_manual_routes(config_id: &str) -> Vec<String> {
load_config_json(config_id)
.and_then(|raw| serde_json::from_str::<NetworkConfig>(&raw).ok())
.map(|config| config.routes)
.unwrap_or_default()
}
fn normalize_route_cidr(route: &str) -> Option<String> {
route
.parse::<IpNet>()
.ok()
.map(|network| match network {
IpNet::V4(net) => net.trunc().to_string(),
IpNet::V6(net) => net.trunc().to_string(),
})
.or_else(|| {
route.parse::<IpAddr>().ok().map(|addr| match addr {
IpAddr::V4(ip) => format!("{}/32", ip),
IpAddr::V6(ip) => format!("{}/128", ip),
})
})
}
fn simplify_routes(routes: Vec<String>) -> Vec<String> {
let mut parsed = routes
.into_iter()
.filter_map(|route| normalize_route_cidr(&route))
.filter_map(|route| route.parse::<IpNet>().ok())
.collect::<Vec<_>>();
parsed.sort_by(|left, right| {
left.prefix_len()
.cmp(&right.prefix_len())
.then_with(|| left.network().to_string().cmp(&right.network().to_string()))
});
let mut simplified = Vec::<IpNet>::new();
'outer: for route in parsed {
for existing in &simplified {
if existing.contains(&route.network()) && existing.prefix_len() <= route.prefix_len() {
continue 'outer;
}
}
simplified.retain(|existing| {
!(route.contains(&existing.network()) && route.prefix_len() <= existing.prefix_len())
});
simplified.push(route);
}
let mut seen = HashSet::new();
simplified
.into_iter()
.map(|route| route.to_string())
.filter(|route| seen.insert(route.clone()))
.collect()
}
pub(crate) fn aggregate_tun_routes(instance: &RuntimeInstanceState) -> Vec<String> {
let virtual_ipv4_cidr = instance
.my_node_info
.as_ref()
.and_then(|info| info.virtual_ipv4_cidr.clone());
let manual_routes = load_manual_routes(&instance.config_id);
let proxy_cidrs = instance
.routes
.iter()
.flat_map(|route| route.proxy_cidrs.iter().cloned())
.collect::<Vec<_>>();
let mut raw_routes = Vec::new();
if let Some(cidr) = virtual_ipv4_cidr.clone() {
raw_routes.push(cidr);
}
raw_routes.extend(manual_routes.iter().cloned());
raw_routes.extend(proxy_cidrs.iter().cloned());
let aggregated_routes = simplify_routes(raw_routes);
hilog_debug!(
"[Rust] aggregate_tun_routes instance={} proxy_cidrs={:?} aggregated_routes={:?}",
instance.instance_id,
proxy_cidrs,
aggregated_routes
);
aggregated_routes
}
pub(crate) fn aggregate_requested_tun_routes(instances: &[RuntimeInstanceState]) -> Vec<String> {
let mut aggregated_routes = Vec::new();
let mut seen_routes = HashSet::new();
for instance in instances.iter().filter(|instance| instance.tun_required) {
for route in aggregate_tun_routes(instance) {
if seen_routes.insert(route.clone()) {
aggregated_routes.push(route);
}
}
}
aggregated_routes
}
@@ -0,0 +1,196 @@
use super::protocol::{TunRequestPayload, broadcast_local_socket_message};
use crate::config::repository::kernel_socket_path;
use crate::get_runtime_snapshot_inner;
use crate::kernel_bridge::routing::aggregate_tun_routes;
use ohos_hilog_binding::{hilog_error, hilog_info};
use once_cell::sync::Lazy;
use std::collections::{HashMap, HashSet};
use std::io::ErrorKind;
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::PathBuf;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread::{self, JoinHandle};
use std::time::Duration;
struct LocalSocketState {
stop_flag: std::sync::Arc<AtomicBool>,
socket_path: PathBuf,
worker: JoinHandle<()>,
}
static LOCAL_SOCKET_STATE: Lazy<Mutex<Option<LocalSocketState>>> = Lazy::new(|| Mutex::new(None));
pub fn start_local_socket_server() -> bool {
let socket_path = match kernel_socket_path() {
Some(path) => path,
None => {
hilog_error!("[Rust] kernel socket path unavailable");
return false;
}
};
match LOCAL_SOCKET_STATE.lock() {
Ok(guard) if guard.is_some() => return true,
Ok(_) => {}
Err(err) => {
hilog_error!("[Rust] lock localsocket state failed: {}", err);
return false;
}
}
if socket_path.exists() {
let _ = std::fs::remove_file(&socket_path);
}
let listener = match UnixListener::bind(&socket_path) {
Ok(listener) => listener,
Err(err) => {
hilog_error!(
"[Rust] bind localsocket failed {}: {}",
socket_path.display(),
err
);
return false;
}
};
if let Err(err) = listener.set_nonblocking(true) {
hilog_error!("[Rust] set localsocket nonblocking failed: {}", err);
let _ = std::fs::remove_file(&socket_path);
return false;
}
let stop_flag = std::sync::Arc::new(AtomicBool::new(false));
let worker_stop_flag = stop_flag.clone();
let worker = thread::spawn(move || {
let mut last_snapshot_json = String::new();
let mut delivered_tun_requests = HashSet::new();
let mut last_tun_route_signatures = HashMap::<String, String>::new();
let mut clients = Vec::<UnixStream>::new();
while !worker_stop_flag.load(Ordering::Relaxed) {
let mut accepted_client = false;
loop {
match listener.accept() {
Ok((stream, _addr)) => {
accepted_client = true;
clients.push(stream);
}
Err(err) if err.kind() == ErrorKind::WouldBlock => break,
Err(err) => {
hilog_error!("[Rust] accept localsocket failed: {}", err);
break;
}
}
}
let snapshot = get_runtime_snapshot_inner();
let snapshot_json = match serde_json::to_string(&snapshot) {
Ok(json) => json,
Err(err) => {
hilog_error!("[Rust] serialize runtime snapshot failed: {}", err);
thread::sleep(Duration::from_millis(250));
continue;
}
};
if accepted_client || snapshot_json != last_snapshot_json {
let _ = broadcast_local_socket_message(
&mut clients,
"runtime_snapshot",
&snapshot_json,
);
last_snapshot_json = snapshot_json;
}
for instance in snapshot.instances.iter() {
if instance.running && instance.tun_required {
let virtual_ipv4 = instance
.my_node_info
.as_ref()
.and_then(|info| info.virtual_ipv4.clone());
let virtual_ipv4_cidr = instance
.my_node_info
.as_ref()
.and_then(|info| info.virtual_ipv4_cidr.clone());
if clients.is_empty() {
continue;
}
if virtual_ipv4.is_none() || virtual_ipv4_cidr.is_none() {
continue;
}
let aggregated_routes = aggregate_tun_routes(instance);
let route_signature = serde_json::to_string(&aggregated_routes)
.unwrap_or_else(|_| "[]".to_string());
let should_send = !delivered_tun_requests.contains(&instance.instance_id)
|| last_tun_route_signatures
.get(&instance.instance_id)
.map(|value| value != &route_signature)
.unwrap_or(true);
if !should_send {
continue;
}
let payload = TunRequestPayload {
config_id: instance.config_id.clone(),
instance_id: instance.instance_id.clone(),
display_name: instance.display_name.clone(),
virtual_ipv4,
virtual_ipv4_cidr,
aggregated_routes,
magic_dns_enabled: instance.magic_dns_enabled,
need_exit_node: instance.need_exit_node,
};
let payload_json = match serde_json::to_string(&payload) {
Ok(json) => json,
Err(err) => {
hilog_error!("[Rust] serialize tun request failed: {}", err);
continue;
}
};
if broadcast_local_socket_message(&mut clients, "tun_request", &payload_json) {
delivered_tun_requests.insert(instance.instance_id.clone());
last_tun_route_signatures
.insert(instance.instance_id.clone(), route_signature);
}
} else {
delivered_tun_requests.remove(&instance.instance_id);
last_tun_route_signatures.remove(&instance.instance_id);
}
}
thread::sleep(Duration::from_millis(250));
}
});
match LOCAL_SOCKET_STATE.lock() {
Ok(mut guard) => {
*guard = Some(LocalSocketState {
stop_flag,
socket_path,
worker,
});
true
}
Err(err) => {
hilog_error!("[Rust] lock localsocket state failed: {}", err);
false
}
}
}
pub fn stop_local_socket_server() -> bool {
let state = match LOCAL_SOCKET_STATE.lock() {
Ok(mut guard) => guard.take(),
Err(err) => {
hilog_error!("[Rust] lock localsocket state failed: {}", err);
return false;
}
};
if let Some(state) = state {
state.stop_flag.store(true, Ordering::Relaxed);
let _ = state.worker.join();
let _ = std::fs::remove_file(state.socket_path);
}
true
}
+439 -139
View File
@@ -1,185 +1,485 @@
mod native_log;
mod config;
mod exports;
mod kernel_bridge;
mod platform;
mod runtime;
use easytier::common::config::{ConfigFileControl, ConfigLoader, TomlConfigLoader};
use config::repository::{
create_config_record, delete_config_record, export_config_toml, get_config_field_value,
get_default_config_json, import_toml_config, init_config_store as init_repo_store,
list_config_meta_json, save_config_record, set_config_field_value, start_kernel_with_config_id,
};
use config::services::schema_service::{
ConfigFieldMapping, NetworkConfigSchema,
get_network_config_field_mappings as build_network_config_field_mappings,
get_network_config_schema as build_network_config_schema,
};
use config::services::share_link_service::{
build_config_share_link as build_config_share_link_inner,
import_config_share_link as import_config_share_link_inner,
parse_config_share_link as parse_config_share_link_inner,
};
use config::storage::config_meta::get_config_display_name;
use config::types::stored_config::{KeyValuePair, SharedConfigLinkPayload};
use easytier::common::constants::EASYTIER_VERSION;
use easytier::common::{
MachineIdOptions,
config::{ConfigFileControl, ConfigLoader, TomlConfigLoader},
};
use easytier::instance_manager::NetworkInstanceManager;
use easytier::proto::api::manage::NetworkConfig;
use easytier::proto::api::manage::NetworkingMethod;
use easytier::web_client::{WebClient, WebClientHooks, run_web_client};
use kernel_bridge::{
aggregate_requested_tun_routes, start_local_socket_server as start_local_socket_server_inner,
stop_local_socket_server as stop_local_socket_server_inner,
};
use napi_derive_ohos::napi;
use ohos_hilog_binding::{hilog_debug, hilog_error};
use ohos_hilog_binding::{hilog_error, hilog_info};
use runtime::state::runtime_state::{
RuntimeAggregateState, TunAggregateState, clear_tun_attached, mark_tun_attached,
runtime_instance_from_running_info,
};
use std::collections::{HashMap, HashSet};
use std::format;
use std::sync::{Arc, Mutex};
use tokio::runtime::{Builder, Runtime};
use uuid::Uuid;
static INSTANCE_MANAGER: once_cell::sync::Lazy<NetworkInstanceManager> =
once_cell::sync::Lazy::new(NetworkInstanceManager::new);
pub(crate) static INSTANCE_MANAGER: once_cell::sync::Lazy<Arc<NetworkInstanceManager>> =
once_cell::sync::Lazy::new(|| Arc::new(NetworkInstanceManager::new()));
static ASYNC_RUNTIME: once_cell::sync::Lazy<Runtime> = once_cell::sync::Lazy::new(|| {
Builder::new_multi_thread()
.enable_all()
.build()
.expect("tokio runtime for easytier-ohrs")
});
static WEB_CLIENTS: once_cell::sync::Lazy<Mutex<HashMap<String, ManagedWebClient>>> =
once_cell::sync::Lazy::new(|| Mutex::new(HashMap::new()));
#[napi(object)]
pub struct KeyValuePair {
pub key: String,
pub value: String,
#[derive(Default)]
struct TrackedWebClientHooks {
instance_ids: Mutex<HashSet<Uuid>>,
}
#[napi]
pub fn easytier_version() -> String {
EASYTIER_VERSION.to_string()
struct ManagedWebClient {
_client: WebClient,
hooks: Arc<TrackedWebClientHooks>,
}
#[napi]
pub fn set_tun_fd(inst_id: String, fd: i32) -> bool {
match Uuid::try_parse(&inst_id) {
Ok(uuid) => match INSTANCE_MANAGER.set_tun_fd(&uuid, fd) {
Ok(_) => {
hilog_debug!("[Rust] set tun fd {} to {}.", fd, inst_id);
true
}
Err(e) => {
hilog_error!("[Rust] cant set tun fd {} to {}. {}", fd, inst_id, e);
false
}
},
Err(e) => {
hilog_error!("[Rust] cant covert {} to uuid. {}", inst_id, e);
#[async_trait::async_trait]
impl WebClientHooks for TrackedWebClientHooks {
async fn post_run_network_instance(&self, id: &Uuid) -> Result<(), String> {
self.instance_ids
.lock()
.map_err(|err| err.to_string())?
.insert(*id);
Ok(())
}
async fn post_remove_network_instances(&self, ids: &[Uuid]) -> Result<(), String> {
let mut guard = self.instance_ids.lock().map_err(|err| err.to_string())?;
for id in ids {
guard.remove(id);
}
Ok(())
}
}
fn is_config_server_config(config: &NetworkConfig) -> bool {
matches!(
NetworkingMethod::try_from(config.networking_method.unwrap_or_default())
.unwrap_or_default(),
NetworkingMethod::PublicServer
) && config
.public_server_url
.as_ref()
.is_some_and(|url| !url.trim().is_empty())
}
fn stop_web_client(config_id: &str) -> bool {
let managed = match WEB_CLIENTS.lock() {
Ok(mut guard) => guard.remove(config_id),
Err(err) => {
hilog_error!("[Rust] stop_web_client lock failed {}", err);
return false;
}
};
let Some(managed) = managed else {
return false;
};
let tracked_ids = managed
.hooks
.instance_ids
.lock()
.map(|guard| guard.iter().copied().collect::<Vec<_>>())
.unwrap_or_default();
drop(managed);
if tracked_ids.is_empty() {
maybe_stop_local_socket_server();
return true;
}
let ret = INSTANCE_MANAGER
.delete_network_instance(tracked_ids)
.map(|_| true)
.unwrap_or_else(|err| {
hilog_error!(
"[Rust] stop config server instances failed {}: {}",
config_id,
err
);
false
});
maybe_stop_local_socket_server();
ret
}
fn ensure_local_socket_server_started() -> bool {
start_local_socket_server_inner()
}
fn maybe_stop_local_socket_server() {
let no_local_instances = INSTANCE_MANAGER.list_network_instance_ids().is_empty();
let no_web_clients = WEB_CLIENTS
.lock()
.map(|guard| guard.is_empty())
.unwrap_or(false);
if no_local_instances && no_web_clients {
let _ = stop_local_socket_server_inner();
}
}
fn run_config_server_instance(config_id: &str, config: &NetworkConfig) -> bool {
if INSTANCE_MANAGER
.list_network_instance_ids()
.iter()
.next()
.is_some()
{
hilog_error!("[Rust] there is a running instance!");
return false;
}
let Some(config_server_url) = config.public_server_url.clone() else {
hilog_error!("[Rust] public_server_url missing for config server mode");
return false;
};
let hooks = Arc::new(TrackedWebClientHooks::default());
let secure_mode = config
.secure_mode
.as_ref()
.map(|mode| mode.enabled)
.unwrap_or(false);
let hostname = config.hostname.clone();
if !ensure_local_socket_server_started() {
return false;
}
let client = ASYNC_RUNTIME.block_on(run_web_client(
&config_server_url,
MachineIdOptions::default(),
hostname,
secure_mode,
INSTANCE_MANAGER.clone(),
Some(hooks.clone()),
));
let client = match client {
Ok(client) => client,
Err(err) => {
hilog_error!("[Rust] start config server failed {}", err);
return false;
}
};
match WEB_CLIENTS.lock() {
Ok(mut guard) => {
guard.insert(
config_id.to_string(),
ManagedWebClient {
_client: client,
hooks,
},
);
true
}
Err(err) => {
hilog_error!("[Rust] store config server client failed {}", err);
false
}
}
}
#[napi]
pub fn default_network_config() -> String {
match NetworkConfig::new_from_config(TomlConfigLoader::default()) {
Ok(result) => serde_json::to_string(&result).unwrap_or_else(|e| format!("ERROR {}", e)),
Err(e) => {
hilog_error!("[Rust] default_network_config failed {}", e);
format!("ERROR {}", e)
}
}
pub(crate) fn build_default_network_config_json() -> Result<String, String> {
let config = NetworkConfig::new_from_config(TomlConfigLoader::default())
.map_err(|e| format!("default_network_config failed {}", e))?;
serde_json::to_string(&config).map_err(|e| format!("default_network_config failed {}", e))
}
#[napi]
pub fn convert_toml_to_network_config(cfg_str: String) -> String {
match TomlConfigLoader::new_from_str(&cfg_str) {
Ok(cfg) => match NetworkConfig::new_from_config(cfg) {
Ok(result) => serde_json::to_string(&result).unwrap_or_else(|e| format!("ERROR {}", e)),
Err(e) => {
hilog_error!("[Rust] convert_toml_to_network_config failed {}", e);
format!("ERROR {}", e)
}
},
Err(e) => {
hilog_error!("[Rust] convert_toml_to_network_config failed {}", e);
format!("ERROR {}", e)
}
}
fn convert_toml_to_network_config_inner(toml_text: &str) -> Result<String, String> {
let config = NetworkConfig::new_from_config(
TomlConfigLoader::new_from_str(toml_text).map_err(|e| e.to_string())?,
)
.map_err(|e| e.to_string())?;
serde_json::to_string(&config).map_err(|e| e.to_string())
}
#[napi]
pub fn parse_network_config(cfg_json: String) -> bool {
match serde_json::from_str::<NetworkConfig>(&cfg_json) {
Ok(cfg) => match cfg.gen_config() {
Ok(toml) => {
hilog_debug!("[Rust] Convert to Toml {}", toml.dump());
true
}
Err(e) => {
hilog_error!("[Rust] parse config failed {}", e);
false
}
},
Err(e) => {
hilog_error!("[Rust] parse config failed {}", e);
false
}
}
fn parse_network_config_inner(cfg_json: &str) -> bool {
serde_json::from_str::<NetworkConfig>(cfg_json)
.ok()
.and_then(|cfg| cfg.gen_config().ok())
.is_some()
}
#[napi]
pub fn run_network_instance(cfg_json: String) -> bool {
let cfg = match serde_json::from_str::<NetworkConfig>(&cfg_json) {
Ok(cfg) => match cfg.gen_config() {
Ok(toml) => toml,
Err(e) => {
hilog_error!("[Rust] parse config failed {}", e);
return false;
}
},
pub(crate) fn run_network_instance_from_json(cfg_json: &str) -> bool {
let config = match serde_json::from_str::<NetworkConfig>(cfg_json) {
Ok(cfg) => cfg,
Err(e) => {
hilog_error!("[Rust] parse config failed {}", e);
return false;
}
};
if INSTANCE_MANAGER.list_network_instance_ids().len() > 0 {
if is_config_server_config(&config) {
let Some(config_id) = config.instance_id.as_deref() else {
hilog_error!("[Rust] config server config missing instance id");
return false;
};
return run_config_server_instance(config_id, &config);
}
let cfg = match config.gen_config() {
Ok(toml) => toml,
Err(e) => {
hilog_error!("[Rust] parse config failed {}", e);
return false;
}
};
if !INSTANCE_MANAGER.list_network_instance_ids().is_empty() {
hilog_error!("[Rust] there is a running instance!");
return false;
}
if !ensure_local_socket_server_started() {
return false;
}
let inst_id = cfg.get_id();
if INSTANCE_MANAGER
.list_network_instance_ids()
.contains(&inst_id)
{
hilog_error!("[Rust] instance {} already exists", inst_id);
return false;
}
INSTANCE_MANAGER
.run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG)
.unwrap();
true
}
#[napi]
pub fn stop_network_instance(inst_names: Vec<String>) {
INSTANCE_MANAGER
.delete_network_instance(
inst_names
.into_iter()
.filter_map(|s| Uuid::parse_str(&s).ok())
.collect(),
)
.unwrap();
hilog_debug!("[Rust] stop_network_instance");
}
#[napi]
pub fn collect_network_infos() -> Vec<KeyValuePair> {
let mut result = Vec::new();
match INSTANCE_MANAGER.collect_network_infos_sync() {
Ok(map) => {
for (uuid, info) in map.iter() {
// convert value to json string
let value = match serde_json::to_string(&info) {
Ok(value) => value,
Err(e) => {
hilog_error!("[Rust] failed to serialize instance {} info: {}", uuid, e);
continue;
}
};
result.push(KeyValuePair {
key: uuid.clone().to_string(),
value: value.clone(),
});
}
}
Err(_) => {}
}
result
}
#[napi]
pub fn collect_running_network() -> Vec<String> {
INSTANCE_MANAGER
.list_network_instance_ids()
.clone()
.into_iter()
.map(|id| id.to_string())
.collect()
}
#[napi]
pub fn is_running_network(inst_id: String) -> bool {
match Uuid::try_parse(&inst_id) {
Ok(uuid) => INSTANCE_MANAGER.list_network_instance_ids().contains(&uuid),
Err(e) => {
hilog_error!("[Rust] cant covert {} to uuid. {}", inst_id, e);
match INSTANCE_MANAGER.run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG) {
Ok(_) => true,
Err(err) => {
hilog_error!("[Rust] start_kernel failed for {}: {}", inst_id, err);
false
}
}
}
fn parse_instance_uuid(config_id: &str) -> Option<Uuid> {
match Uuid::parse_str(config_id) {
Ok(uuid) => Some(uuid),
Err(err) => {
hilog_error!("[Rust] invalid config_id {}: {}", config_id, err);
None
}
}
}
#[napi]
pub fn init_config_store(root_dir: String) -> bool {
exports::config_api::init_config_store(root_dir)
}
#[napi]
pub fn list_configs() -> String {
exports::config_api::list_configs()
}
#[napi]
pub fn get_config_display_name_by_id(config_id: String) -> Option<String> {
get_config_display_name(&config_id)
}
#[napi]
pub fn save_config(config_id: String, display_name: String, config_json: String) -> bool {
exports::config_api::save_config(config_id, display_name, config_json)
}
#[napi]
pub fn create_config(config_id: String, display_name: String) -> bool {
exports::config_api::create_config(config_id, display_name)
}
#[napi]
pub fn rename_stored_config(config_id: String, display_name: String) -> bool {
config::storage::config_meta::set_config_display_name(config_id, display_name).is_some()
}
#[napi]
pub fn delete_stored_config_meta(config_id: String) -> bool {
exports::config_api::delete_stored_config_meta(config_id)
}
#[napi]
pub fn get_config(config_id: String) -> Option<String> {
exports::config_api::get_config(config_id)
}
#[napi]
pub fn get_default_config() -> Option<String> {
exports::config_api::get_default_config()
}
#[napi]
pub fn get_config_field(config_id: String, field: String) -> Option<String> {
exports::config_api::get_config_field(config_id, field)
}
#[napi]
pub fn set_config_field(config_id: String, field: String, json_value: String) -> bool {
exports::config_api::set_config_field(config_id, field, json_value)
}
#[napi]
pub fn import_toml(toml_text: String, display_name: Option<String>) -> Option<String> {
exports::config_api::import_toml(toml_text, display_name)
}
#[napi]
pub fn export_toml(config_id: String) -> Option<String> {
exports::config_api::export_toml(config_id)
}
#[napi]
pub fn start_kernel(config_id: String) -> bool {
exports::runtime_api::start_kernel(config_id, start_kernel_with_config_id)
}
#[napi]
pub fn stop_kernel(config_id: String) -> bool {
exports::runtime_api::stop_kernel(
config_id,
stop_web_client,
parse_instance_uuid,
maybe_stop_local_socket_server,
)
}
#[napi]
pub fn stop_network_instance(config_ids: Vec<String>) -> bool {
exports::runtime_api::stop_network_instance(config_ids, stop_kernel)
}
#[napi]
pub fn easytier_version() -> String {
EASYTIER_VERSION.to_string()
}
#[napi]
pub fn default_network_config() -> String {
get_default_config().unwrap_or_else(|| "{}".to_string())
}
#[napi]
pub fn convert_toml_to_network_config(toml_text: String) -> String {
convert_toml_to_network_config_inner(&toml_text).unwrap_or_else(|err| format!("ERROR: {err}"))
}
#[napi]
pub fn parse_network_config(cfg_json: String) -> bool {
parse_network_config_inner(&cfg_json)
}
#[napi]
pub fn run_network_instance(cfg_json: String) -> bool {
run_network_instance_from_json(&cfg_json)
}
#[napi]
pub fn collect_network_infos() -> Vec<KeyValuePair> {
exports::runtime_api::collect_network_infos()
}
#[napi]
pub fn set_tun_fd(config_id: String, fd: i32) -> bool {
exports::runtime_api::set_tun_fd(config_id, fd, parse_instance_uuid)
}
#[napi]
pub fn get_network_config_schema() -> NetworkConfigSchema {
build_network_config_schema()
}
#[napi]
pub fn get_network_config_field_mappings() -> Vec<ConfigFieldMapping> {
build_network_config_field_mappings()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exported_plain_object_schema_contains_core_networkconfig_metadata() {
let schema = get_network_config_schema();
assert_eq!(schema.name, "NetworkConfig");
assert_eq!(schema.node_kind, "schema");
assert!(
schema
.children
.iter()
.any(|field| field.name == "network_name")
);
let secure_mode = schema
.children
.iter()
.find(|field| field.name == "secure_mode")
.expect("secure_mode field");
assert!(
secure_mode
.children
.iter()
.any(|field| field.name == "enabled")
);
}
}
#[napi]
pub fn get_runtime_snapshot() -> RuntimeAggregateState {
exports::runtime_api::get_runtime_snapshot()
}
pub(crate) fn get_runtime_snapshot_inner() -> RuntimeAggregateState {
exports::runtime_api::get_runtime_snapshot_inner()
}
#[napi]
pub fn build_config_share_link(config_id: String, only_start: Option<bool>) -> Option<String> {
build_config_share_link_inner(&config_id, None, only_start.unwrap_or(false))
}
#[napi]
pub fn parse_config_share_link(share_link: String) -> Option<SharedConfigLinkPayload> {
parse_config_share_link_inner(&share_link)
}
#[napi]
pub fn import_config_share_link(
share_link: String,
display_name_override: Option<String>,
) -> Option<String> {
import_config_share_link_inner(&share_link, display_name_override)
}
@@ -0,0 +1 @@
pub(crate) mod logging;
@@ -0,0 +1 @@
pub(crate) mod native_log;
@@ -0,0 +1 @@
pub(crate) mod state;
@@ -0,0 +1 @@
pub(crate) mod runtime_state;
@@ -0,0 +1,293 @@
use easytier::proto::{api, common};
use napi_derive_ohos::napi;
use serde::Serialize;
use std::collections::HashSet;
use std::sync::Mutex;
static ATTACHED_TUN_INSTANCE_IDS: once_cell::sync::Lazy<Mutex<HashSet<String>>> =
once_cell::sync::Lazy::new(|| Mutex::new(HashSet::new()));
pub fn mark_tun_attached(instance_id: &str) {
if let Ok(mut guard) = ATTACHED_TUN_INSTANCE_IDS.lock() {
guard.insert(instance_id.to_string());
}
}
pub fn clear_tun_attached(instance_id: &str) {
if let Ok(mut guard) = ATTACHED_TUN_INSTANCE_IDS.lock() {
guard.remove(instance_id);
}
}
pub fn is_tun_attached(instance_id: &str) -> bool {
ATTACHED_TUN_INSTANCE_IDS
.lock()
.map(|guard| guard.contains(instance_id))
.unwrap_or(false)
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
#[napi(object)]
pub struct PeerConnStats {
pub rx_bytes: i64,
pub tx_bytes: i64,
pub rx_packets: i64,
pub tx_packets: i64,
pub latency_us: i64,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
#[napi(object)]
pub struct PeerConnInfo {
pub conn_id: String,
pub my_peer_id: i64,
pub peer_id: i64,
pub features: Vec<String>,
pub tunnel_type: Option<String>,
pub local_addr: Option<String>,
pub remote_addr: Option<String>,
pub resolved_remote_addr: Option<String>,
pub stats: Option<PeerConnStats>,
pub loss_rate: Option<f64>,
pub is_client: bool,
pub network_name: Option<String>,
pub is_closed: bool,
pub secure_auth_level: Option<i32>,
pub peer_identity_type: Option<i32>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
#[napi(object)]
pub struct PeerInfo {
pub peer_id: i64,
pub default_conn_id: Option<String>,
pub directly_connected_conns: Vec<String>,
pub conns: Vec<PeerConnInfo>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
#[napi(object)]
pub struct RouteView {
pub peer_id: i64,
pub hostname: Option<String>,
pub ipv4: Option<String>,
pub ipv4_cidr: Option<String>,
pub ipv6_cidr: Option<String>,
pub proxy_cidrs: Vec<String>,
pub next_hop_peer_id: Option<i64>,
pub cost: Option<i32>,
pub path_latency: Option<i64>,
pub udp_nat_type: Option<i32>,
pub tcp_nat_type: Option<i32>,
pub inst_id: Option<String>,
pub version: Option<String>,
pub is_public_server: Option<bool>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
#[napi(object)]
pub struct MyNodeInfo {
pub virtual_ipv4: Option<String>,
pub virtual_ipv4_cidr: Option<String>,
pub hostname: Option<String>,
pub version: Option<String>,
pub peer_id: Option<i64>,
pub listeners: Vec<String>,
pub vpn_portal_cfg: Option<String>,
pub udp_nat_type: Option<i32>,
pub tcp_nat_type: Option<i32>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
#[napi(object)]
pub struct RuntimeInstanceState {
pub config_id: String,
pub instance_id: String,
pub display_name: String,
pub running: bool,
pub tun_required: bool,
pub tun_attached: bool,
pub magic_dns_enabled: bool,
pub need_exit_node: bool,
pub error_message: Option<String>,
pub my_node_info: Option<MyNodeInfo>,
pub events: Vec<String>,
pub routes: Vec<RouteView>,
pub peers: Vec<PeerInfo>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
#[napi(object)]
pub struct TunAggregateState {
pub active: bool,
pub attached_instance_ids: Vec<String>,
pub aggregated_routes: Vec<String>,
pub dns_servers: Vec<String>,
pub need_rebuild: bool,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
#[napi(object)]
pub struct RuntimeAggregateState {
pub instances: Vec<RuntimeInstanceState>,
pub tun: TunAggregateState,
pub running_instance_count: i32,
}
fn stringify_ipv4_inet(value: Option<common::Ipv4Inet>) -> Option<String> {
value.map(|v| v.to_string())
}
fn stringify_ipv6_inet(value: Option<common::Ipv6Inet>) -> Option<String> {
value.map(|v| v.to_string())
}
fn stringify_url(value: Option<common::Url>) -> Option<String> {
value.map(|v| v.to_string())
}
fn stringify_uuid(value: Option<common::Uuid>) -> Option<String> {
value.map(|v| v.to_string())
}
fn optional_u32_to_i64(value: Option<u32>) -> Option<i64> {
value.map(|v| v as i64)
}
fn optional_i32_to_i64(value: Option<i32>) -> Option<i64> {
value.map(|v| v as i64)
}
fn route_to_view(route: api::instance::Route) -> RouteView {
let stun = route.stun_info;
let feature_flag = route.feature_flag;
RouteView {
peer_id: route.peer_id as i64,
hostname: (!route.hostname.is_empty()).then_some(route.hostname),
ipv4: route
.ipv4_addr
.as_ref()
.and_then(|inet| inet.address.as_ref())
.map(|addr| addr.to_string()),
ipv4_cidr: stringify_ipv4_inet(route.ipv4_addr),
ipv6_cidr: stringify_ipv6_inet(route.ipv6_addr),
proxy_cidrs: route.proxy_cidrs,
next_hop_peer_id: optional_u32_to_i64(route.next_hop_peer_id_latency_first)
.or_else(|| Some(route.next_hop_peer_id as i64)),
cost: Some(route.cost),
path_latency: optional_i32_to_i64(route.path_latency_latency_first)
.or_else(|| Some(route.path_latency as i64)),
udp_nat_type: stun.as_ref().map(|info| info.udp_nat_type),
tcp_nat_type: stun.as_ref().map(|info| info.tcp_nat_type),
inst_id: (!route.inst_id.is_empty()).then_some(route.inst_id),
version: (!route.version.is_empty()).then_some(route.version),
is_public_server: feature_flag.map(|flag| flag.is_public_server),
}
}
fn peer_conn_to_view(conn: api::instance::PeerConnInfo) -> PeerConnInfo {
let stats = conn.stats.map(|stats| PeerConnStats {
rx_bytes: stats.rx_bytes as i64,
tx_bytes: stats.tx_bytes as i64,
rx_packets: stats.rx_packets as i64,
tx_packets: stats.tx_packets as i64,
latency_us: stats.latency_us as i64,
});
PeerConnInfo {
conn_id: conn.conn_id,
my_peer_id: conn.my_peer_id as i64,
peer_id: conn.peer_id as i64,
features: conn.features,
tunnel_type: conn.tunnel.as_ref().map(|t| t.tunnel_type.clone()),
local_addr: conn
.tunnel
.as_ref()
.and_then(|t| stringify_url(t.local_addr.clone())),
remote_addr: conn
.tunnel
.as_ref()
.and_then(|t| stringify_url(t.remote_addr.clone())),
resolved_remote_addr: conn
.tunnel
.as_ref()
.and_then(|t| stringify_url(t.resolved_remote_addr.clone())),
stats,
loss_rate: Some(conn.loss_rate as f64),
is_client: conn.is_client,
network_name: (!conn.network_name.is_empty()).then_some(conn.network_name),
is_closed: conn.is_closed,
secure_auth_level: Some(conn.secure_auth_level),
peer_identity_type: Some(conn.peer_identity_type),
}
}
fn peer_to_view(peer: api::instance::PeerInfo) -> PeerInfo {
PeerInfo {
peer_id: peer.peer_id as i64,
default_conn_id: stringify_uuid(peer.default_conn_id),
directly_connected_conns: peer
.directly_connected_conns
.into_iter()
.map(|id| id.to_string())
.collect(),
conns: peer.conns.into_iter().map(peer_conn_to_view).collect(),
}
}
fn my_node_info_to_view(info: api::manage::MyNodeInfo) -> MyNodeInfo {
MyNodeInfo {
virtual_ipv4: info
.virtual_ipv4
.as_ref()
.and_then(|inet| inet.address.as_ref())
.map(|addr| addr.to_string()),
virtual_ipv4_cidr: stringify_ipv4_inet(info.virtual_ipv4),
hostname: (!info.hostname.is_empty()).then_some(info.hostname),
version: (!info.version.is_empty()).then_some(info.version),
peer_id: Some(info.peer_id as i64),
listeners: info
.listeners
.into_iter()
.map(|url| url.to_string())
.collect(),
vpn_portal_cfg: info.vpn_portal_cfg,
udp_nat_type: info.stun_info.as_ref().map(|stun| stun.udp_nat_type),
tcp_nat_type: info.stun_info.as_ref().map(|stun| stun.tcp_nat_type),
}
}
pub fn runtime_instance_from_running_info(
config_id: String,
display_name: String,
magic_dns_enabled: bool,
need_exit_node: bool,
info: api::manage::NetworkInstanceRunningInfo,
) -> RuntimeInstanceState {
let tun_attached = info.running && is_tun_attached(&config_id);
let tun_required = info.running && (info.dev_name != "no_tun" || tun_attached);
RuntimeInstanceState {
config_id: config_id.clone(),
instance_id: config_id,
display_name,
running: info.running,
tun_required,
tun_attached,
magic_dns_enabled,
need_exit_node,
error_message: info.error_msg,
my_node_info: info.my_node_info.map(my_node_info_to_view),
events: info.events,
routes: info.routes.into_iter().map(route_to_view).collect(),
peers: info.peers.into_iter().map(peer_to_view).collect(),
}
}
@@ -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"] }
@@ -7,15 +7,15 @@ 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::{
@@ -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>>,
}
@@ -465,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),
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "easytier-gui",
"type": "module",
"version": "2.6.0",
"version": "2.6.4",
"private": true,
"packageManager": "pnpm@9.12.1+sha512.e5a7e52a4183a02d5931057f7a0dbff9d5e9ce3161e33fa68ae392125b79282a8a8a470a51dfc8a0ed86221442eb2fb57019b0990ed24fab519bf0e1bc5ccfc4",
"scripts": {
@@ -59,4 +59,4 @@
"vue-i18n": "^10.0.0",
"vue-tsc": "^2.1.10"
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "easytier-gui"
version = "2.6.0"
version = "2.6.4"
description = "EasyTier GUI"
authors = ["you"]
edition.workspace = true
@@ -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")
+208 -34
View File
@@ -15,7 +15,7 @@ use easytier::rpc_service::remote_client::{
use easytier::web_client::{self, WebClient};
use easytier::{
common::{
config::{ConfigLoader, FileLoggerConfig, LoggingConfigBuilder, TomlConfigLoader},
config::{ConfigLoader, ConfigSource, FileLoggerConfig, LoggingConfig, TomlConfigLoader},
log,
},
instance_manager::NetworkInstanceManager,
@@ -118,7 +118,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)
@@ -207,13 +207,17 @@ async fn update_network_config_state(
.map_err(|e: uuid::Error| e.to_string())?;
let client_manager = get_client_manager!()?;
if !disabled {
let cfg = client_manager
.handle_get_network_config(app.clone(), instance_id)
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)
.pre_run_network_instance_hook(
&app,
&toml_config,
manager::PersistedConfigSource::from_runtime_source(source),
)
.await?;
}
client_manager
@@ -272,7 +276,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!()?
@@ -484,10 +488,18 @@ 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,
easytier::common::MachineIdOptions {
explicit_machine_id: None,
state_dir: Some(machine_id_state_dir),
},
None,
false,
instance_manager,
@@ -612,7 +624,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
}
@@ -631,14 +647,87 @@ mod manager {
}
}
#[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()
}
}
@@ -655,13 +744,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(())
}
@@ -680,8 +768,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)
}
@@ -693,8 +787,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(())
@@ -811,17 +911,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?;
@@ -842,6 +961,7 @@ 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.to_string())
@@ -849,9 +969,24 @@ mod manager {
#[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
@@ -859,6 +994,7 @@ mod manager {
app,
instance_id,
NetworkConfig::new_from_config(cfg).map_err(|e| e.to_string())?,
source,
)
.map_err(|e| e.to_string())?;
@@ -962,15 +1098,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),
);
}
@@ -986,12 +1122,12 @@ mod manager {
.storage
.network_configs
.get(&uuid)
.map(|i| i.value().1.clone());
let Some(config) = config else {
.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)
self.pre_run_network_instance_hook(&app, &toml_config, source)
.await
.map_err(|e| anyhow::anyhow!(e))?;
client
@@ -1001,6 +1137,7 @@ mod manager {
inst_id: None,
config: Some(config),
overwrite: false,
source: source.to_runtime_source().to_rpc(),
},
)
.await?;
@@ -1032,6 +1169,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"))]
@@ -1149,7 +1324,7 @@ pub fn run_gui() -> std::process::ExitCode {
let Ok(log_dir) = get_log_dir(app.app_handle()) else {
return Ok(());
};
let config = LoggingConfigBuilder::default()
let config = LoggingConfig::builder()
.file_logger(FileLoggerConfig {
dir: Some(log_dir.to_string_lossy().to_string()),
level: None,
@@ -1157,8 +1332,7 @@ pub fn run_gui() -> std::process::ExitCode {
size_mb: None,
count: None,
})
.build()
.map_err(|e| e.to_string())?;
.build();
let Ok(_) = log::init(&config, true) else {
return Ok(());
};
+2 -2
View File
@@ -17,7 +17,7 @@
"createUpdaterArtifacts": false
},
"productName": "easytier-gui",
"version": "2.6.0",
"version": "2.6.4",
"identifier": "com.kkrainbow.easytier",
"plugins": {
"shell": {
@@ -36,4 +36,4 @@
"csp": null
}
}
}
}
+39 -2
View File
@@ -6,6 +6,7 @@ import { GetNetworkMetasResponse } from 'node_modules/easytier-frontend-lib/dist
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,6 +17,39 @@ 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: NetworkTypes.toBackendNetworkConfig(cfg) })
}
@@ -71,9 +105,12 @@ export async function getConfig(instanceId: string) {
}
export async function sendConfigs(enabledNetworks: string[]) {
const networkList: NetworkConfig[] = JSON.parse(localStorage.getItem('networkList') || '[]');
const networkList = parseStoredConfigs(localStorage.getItem('networkList'))
return await invoke('load_configs', {
configs: networkList.map((config) => NetworkTypes.toBackendNetworkConfig(NetworkTypes.normalizeNetworkConfig(config))),
configs: networkList.map(({ config, source }) => ({
config: NetworkTypes.toBackendNetworkConfig(config),
source,
})),
enabledNetworks
})
}
+13 -2
View File
@@ -3,6 +3,11 @@ 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',
PRE_RUN_NETWORK_INSTANCE: 'pre_run_network_instance',
@@ -13,9 +18,15 @@ 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.map((config) => NetworkTypes.normalizeNetworkConfig(config))));
localStorage.setItem(
'networkList',
JSON.stringify(event.payload.map(({ config, source }) => ({
config: NetworkTypes.normalizeNetworkConfig(config),
source: source ?? 'legacy',
}))),
);
}
function normalizeInstanceIdPayload(payload: unknown): string {
+1
View File
@@ -18,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 {
+20 -3
View File
@@ -16,7 +16,7 @@ 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()
@@ -85,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
@@ -134,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,
@@ -149,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") {
@@ -157,6 +173,7 @@ 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;
-20
View File
@@ -1,20 +0,0 @@
[package]
name = "easytier-rpc-build"
description = "Protobuf RPC Service Generator for EasyTier"
version = "0.1.0"
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"]
license-file = "LICENSE"
readme = "README.md"
[dependencies]
heck = "0.5"
prost-build = "0.13"
[features]
default = []
internal-namespace = []
-1
View File
@@ -1 +0,0 @@
../LICENSE
-3
View File
@@ -1,3 +0,0 @@
# Introduction
This is a protobuf rpc service stub generator for [EasyTier](https://github.com/EasyTier/EasyTier) project.
-449
View File
@@ -1,449 +0,0 @@
extern crate heck;
extern crate prost_build;
use std::fmt;
#[cfg(feature = "internal-namespace")]
const NAMESPACE: &str = "crate::proto::rpc_types";
#[cfg(not(feature = "internal-namespace"))]
const NAMESPACE: &str = "easytier::proto::rpc_types";
/// The service generator to be used with `prost-build` to generate RPC implementations for
/// `prost-simple-rpc`.
///
/// See the crate-level documentation for more info.
#[allow(missing_copy_implementations)]
#[derive(Clone, Debug, Default)]
pub struct ServiceGenerator {
_private: (),
}
impl prost_build::ServiceGenerator for ServiceGenerator {
fn generate(&mut self, service: prost_build::Service, mut buf: &mut String) {
use std::fmt::Write;
let descriptor_name = format!("{}Descriptor", service.name);
let server_name = format!("{}Server", service.name);
let client_name = format!("{}Client", service.name);
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();
let mut client_own_methods = String::new();
let mut match_name_methods = String::new();
let mut match_proto_name_methods = String::new();
let mut match_input_type_methods = String::new();
let mut match_input_proto_type_methods = String::new();
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();
for (idx, method) in service.methods.iter().enumerate() {
assert!(
!method.client_streaming,
"Client streaming not yet supported for method {}",
method.proto_name
);
assert!(
!method.server_streaming,
"Server streaming not yet supported for method {}",
method.proto_name
);
ServiceGenerator::write_comments(&mut trait_methods, 4, &method.comments).unwrap();
writeln!(
trait_methods,
r#" async fn {name}(&self, ctrl: Self::Controller, input: {input_type}) -> {namespace}::error::Result<{output_type}>;"#,
name = method.name,
input_type = method.input_type,
output_type = method.output_type,
namespace = NAMESPACE,
)
.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,
" {name} = {index},",
name = method.proto_name,
index = idx + 1
)
.unwrap();
writeln!(
match_method_try_from,
" {index} => Ok({service_name}MethodDescriptor::{name}),",
service_name = service.name,
name = method.proto_name,
index = idx + 1,
)
.unwrap();
writeln!(
list_enum_methods,
" {service_name}MethodDescriptor::{name},",
service_name = service.name,
name = method.proto_name
)
.unwrap();
writeln!(
client_methods,
r#" async fn {name}(&self, ctrl: H::Controller, input: {input_type}) -> {namespace}::error::Result<{output_type}> {{
{client_name}Client::{name}_inner(self.0.clone(), ctrl, input).await
}}"#,
name = method.name,
input_type = method.input_type,
output_type = method.output_type,
client_name = service.name,
namespace = NAMESPACE,
)
.unwrap();
writeln!(
client_own_methods,
r#" async fn {name}_inner(handler: H, ctrl: H::Controller, input: {input_type}) -> {namespace}::error::Result<{output_type}> {{
{namespace}::__rt::call_method(handler, ctrl, {method_descriptor_name}::{proto_name}, input).await
}}"#,
name = method.name,
method_descriptor_name = method_descriptor_name,
proto_name = method.proto_name,
input_type = method.input_type,
output_type = method.output_type,
namespace = NAMESPACE,
).unwrap();
let case = format!(
" {service_name}MethodDescriptor::{proto_name} => ",
service_name = service.name,
proto_name = method.proto_name
);
writeln!(match_name_methods, "{}{:?},", case, method.name).unwrap();
writeln!(match_proto_name_methods, "{}{:?},", case, method.proto_name).unwrap();
writeln!(
match_input_type_methods,
"{}::std::any::TypeId::of::<{}>(),",
case, method.input_type
)
.unwrap();
writeln!(
match_input_proto_type_methods,
"{}{:?},",
case, method.input_proto_type
)
.unwrap();
writeln!(
match_output_type_methods,
"{}::std::any::TypeId::of::<{}>(),",
case, method.output_type
)
.unwrap();
writeln!(
match_output_proto_type_methods,
"{}{:?},",
case, method.output_proto_type
)
.unwrap();
write!(
match_handle_methods,
r#"{} {{
let decoded: {input_type} = {namespace}::__rt::decode(input)?;
let ret = service.{name}(ctrl, decoded).await?;
{namespace}::__rt::encode(ret)
}}
"#,
case,
input_type = method.input_type,
name = method.name,
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();
write!(
buf,
r#"
#[async_trait::async_trait]
#[auto_impl::auto_impl(&, Arc, Box)]
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}`.
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Default)]
pub struct {descriptor_name};
/// Methods available on a `{name}`.
///
/// This can be used as a key when routing requests for servers/clients of a `{name}`.
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
#[repr(u8)]
pub enum {method_descriptor_name} {{
{enum_methods}
}}
impl std::convert::TryFrom<u8> for {method_descriptor_name} {{
type Error = {namespace}::error::Error;
fn try_from(value: u8) -> {namespace}::error::Result<Self> {{
match value {{
{match_method_try_from}
_ => Err({namespace}::error::Error::InvalidMethodIndex(value, "{name}".to_string())),
}}
}}
}}
/// A client for a `{name}`.
///
/// This implements the `{name}` trait by dispatching all method calls to the supplied `Handler`.
#[derive(Clone, Debug)]
pub struct {client_name}<H>(H) where H: {namespace}::handler::Handler;
impl<H> {client_name}<H> where H: {namespace}::handler::Handler<Descriptor = {descriptor_name}> {{
/// Creates a new client instance that delegates all method calls to the supplied handler.
pub fn new(handler: H) -> {client_name}<H> {{
{client_name}(handler)
}}
}}
impl<H> {client_name}<H> where H: {namespace}::handler::Handler<Descriptor = {descriptor_name}> {{
{client_own_methods}
}}
#[async_trait::async_trait]
impl<H> {name} for {client_name}<H> where H: {namespace}::handler::Handler<Descriptor = {descriptor_name}> {{
type Controller = H::Controller;
{client_methods}
}}
pub struct {client_name}Factory<C: {namespace}::controller::Controller>(std::marker::PhantomData<C>);
impl<C: {namespace}::controller::Controller> Clone for {client_name}Factory<C> {{
fn clone(&self) -> Self {{
Self(std::marker::PhantomData)
}}
}}
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 + Sync + 'static>;
type Controller = C;
fn new(handler: impl {namespace}::handler::Handler<Descriptor = Self::Descriptor, Controller = Self::Controller>) -> Self::ClientImpl {{
Box::new({client_name}::new(handler))
}}
}}
/// A server for a `{name}`.
///
/// This implements the `Server` trait by handling requests and dispatch them to methods on the
/// supplied `{name}`.
#[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> {{
{server_name}(service)
}}
async fn call_inner(
service: A,
method: {method_descriptor_name},
ctrl: A::Controller,
input: ::bytes::Bytes)
-> {namespace}::error::Result<::bytes::Bytes> {{
match method {{
{match_handle_methods}
}}
}}
}}
impl {namespace}::descriptor::ServiceDescriptor for {descriptor_name} {{
type Method = {method_descriptor_name};
fn name(&self) -> &'static str {{ {name:?} }}
fn proto_name(&self) -> &'static str {{ {proto_name:?} }}
fn package(&self) -> &'static str {{ {package:?} }}
fn methods(&self) -> &'static [Self::Method] {{
&[ {list_enum_methods} ]
}}
}}
#[async_trait::async_trait]
impl<A> {namespace}::handler::Handler for {server_name}<A>
where
A: {name} + Clone + Send + Sync + 'static {{
type Descriptor = {descriptor_name};
type Controller = A::Controller;
async fn call(
&self,
ctrl: A::Controller,
method: {method_descriptor_name},
input: ::bytes::Bytes)
-> {namespace}::error::Result<::bytes::Bytes> {{
{server_name}::call_inner(self.0.clone(), method, ctrl, input).await
}}
}}
impl {namespace}::descriptor::MethodDescriptor for {method_descriptor_name} {{
fn name(&self) -> &'static str {{
match *self {{
{match_name_methods}
}}
}}
fn proto_name(&self) -> &'static str {{
match *self {{
{match_proto_name_methods}
}}
}}
fn input_type(&self) -> ::std::any::TypeId {{
match *self {{
{match_input_type_methods}
}}
}}
fn input_proto_type(&self) -> &'static str {{
match *self {{
{match_input_proto_type_methods}
}}
}}
fn output_type(&self) -> ::std::any::TypeId {{
match *self {{
{match_output_type_methods}
}}
}}
fn output_proto_type(&self) -> &'static str {{
match *self {{
{match_output_proto_type_methods}
}}
}}
fn index(&self) -> u8 {{
*self as u8
}}
}}
"#,
name = service.name,
descriptor_name = descriptor_name,
server_name = server_name,
client_name = client_name,
method_descriptor_name = 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,
client_methods = client_methods,
match_name_methods = match_name_methods,
match_proto_name_methods = match_proto_name_methods,
match_input_type_methods = match_input_type_methods,
match_input_proto_type_methods = match_input_proto_type_methods,
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();
}
}
impl ServiceGenerator {
fn write_comments<W>(
mut write: W,
indent: usize,
comments: &prost_build::Comments,
) -> fmt::Result
where
W: fmt::Write,
{
for comment in &comments.leading {
for line in comment.lines().filter(|s| !s.is_empty()) {
writeln!(write, "{}///{}", " ".repeat(indent), line)?;
}
}
Ok(())
}
}
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "easytier-web"
version = "2.6.0"
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."
@@ -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"
@@ -81,6 +81,7 @@ 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' },
@@ -98,6 +99,8 @@ const bool_flags: BoolFlag[] = [
{ 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' },
@@ -2,7 +2,7 @@
import { AutoComplete, Button, Dialog, InputNumber, InputText } from 'primevue'
import InputGroup from 'primevue/inputgroup'
import InputGroupAddon from 'primevue/inputgroupaddon'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps<{
@@ -13,26 +13,9 @@ const props = defineProps<{
const { t } = useI18n()
const url = defineModel<string>({ required: true })
const editing = ref(false)
const container = ref<HTMLElement | null>(null)
const internalCompact = ref(false)
const hostFocused = ref(false)
onMounted(() => {
if (container.value) {
const observer = new ResizeObserver(entries => {
for (const entry of entries) {
internalCompact.value = entry.contentRect.width < 400
}
})
observer.observe(container.value)
onUnmounted(() => {
observer.disconnect()
})
}
})
const parseUrl = (val: string | null | undefined) => {
const 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
@@ -55,13 +38,16 @@ const parseUrl = (val: string | null | undefined) => {
if (ipv6End > 0) {
const host = hostAndMaybePort.slice(0, ipv6End + 1)
const remain = hostAndMaybePort.slice(ipv6End + 1)
const port = remain.startsWith(':') ? getValidPort(remain.slice(1), proto) : (props.protos[proto] ?? 11010)
// 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
const port = portMatch ? parseInt(portMatch[2]) : (props.protos[proto] ?? 11010)
// 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 }
}
@@ -72,28 +58,26 @@ const parseUrl = (val: string | null | undefined) => {
if (parsedByPattern) {
return parsedByPattern
}
return { proto: 'tcp', host: '', port: 11010 }
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 }, forceDefaultHost = false) => {
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
}
let port = value.port
if (isNaN(parseInt(port as any))) {
port = props.protos[proto] ?? 11010
}
if (props.protos[proto] === 0) {
// 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}:${port}`
return `${proto}://${host}:${value.port}`
}
const syncUrlFromInternal = (forceDefaultHost = false) => {
@@ -168,27 +152,30 @@ const onProtoChange = (newProto: string) => {
</script>
<template>
<div ref="container" class="w-full">
<InputGroup v-if="!internalCompact" class="w-full">
<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"
<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"
fluid />
: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 v-else class="flex justify-between items-center p-2 border rounded w-full">
<span class="truncate mr-2">{{ url }}</span>
<div class="flex items-center">
<Button icon="pi pi-pencil" class="p-button-sm p-button-text" @click="editing = true" />
<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>
@@ -207,7 +194,8 @@ const onProtoChange = (newProto: string) => {
</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" />
<InputNumber v-model="internalValue.port" :format="false" :min="1" :max="65535" class="w-full"
:placeholder="String(protos[internalValue.proto] ?? 11010)" />
</div>
</div>
<template #footer>
@@ -219,6 +207,28 @@ const onProtoChange = (newProto: string) => {
</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) {
@@ -104,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 流量,降低传输延迟,提升传输速度。
@@ -157,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处理
@@ -254,6 +263,7 @@ event:
DhcpIpv4Conflicted: DHCP IPv4地址冲突
PortForwardAdded: 端口转发添加
ProxyCidrsUpdated: 子网代理CIDR更新
UdpBroadcastRelayStartResult: UDP广播中继启动结果
web:
login:
@@ -103,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.
@@ -156,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
@@ -254,6 +263,7 @@ event:
DhcpIpv4Conflicted: DhcpIpv4Conflicted
PortForwardAdded: PortForwardAdded
ProxyCidrsUpdated: ProxyCidrsUpdated
UdpBroadcastRelayStartResult: UDP Broadcast Relay Start Result
web:
login:
@@ -115,6 +115,7 @@ 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
@@ -132,6 +133,8 @@ export interface NetworkConfig {
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
@@ -190,6 +193,7 @@ 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,
@@ -207,6 +211,8 @@ export function DEFAULT_NETWORK_CONFIG(): NetworkConfig {
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: [],
@@ -443,4 +449,6 @@ export enum EventType {
PortForwardAdded = 'PortForwardAdded', // PortForwardConfigPb
ProxyCidrsUpdated = 'ProxyCidrsUpdated', // string[], string[]
UdpBroadcastRelayStartResult = 'UdpBroadcastRelayStartResult', // { capture_backend?: string, error?: string }
}
+1
View File
@@ -365,6 +365,7 @@ mod tests {
let _c = WebClient::new(
connector,
"test",
uuid::Uuid::new_v4(),
"test",
false,
Arc::new(NetworkInstanceManager::new()),
+732 -44
View File
@@ -7,25 +7,70 @@ use std::{
use anyhow::Context;
use easytier::{
common::scoped_task::ScopedTask,
common::config::ConfigSource,
proto::{
api::manage::{
NetworkConfig, RunNetworkInstanceRequest, WebClientService,
WebClientServiceClientFactory,
ConfigSource as RpcConfigSource, NetworkConfig, NetworkMeta, RunNetworkInstanceRequest,
WebClientService, WebClientServiceClientFactory,
},
rpc_impl::bidirect::BidirectRpcManager,
rpc_types::{self, controller::BaseController},
web::{HeartbeatRequest, HeartbeatResponse, WebServerService, WebServerServiceServer},
},
rpc_service::remote_client::{ListNetworkProps, Storage as _},
rpc_service::remote_client::{ListNetworkProps, PersistentConfig as _, Storage as _},
tunnel::Tunnel,
};
use tokio::sync::{RwLock, broadcast};
use tokio_util::task::AbortOnDropHandle;
use super::storage::{Storage, StorageToken, WeakRefStorage};
use crate::FeatureFlags;
use crate::webhook::SharedWebhookConfig;
const LEGACY_NETWORK_CONFIG_SOURCE: &str = "legacy";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PersistedConfigSource {
User,
Webhook,
Legacy,
}
impl PersistedConfigSource {
fn from_db(source: &str) -> Self {
match source {
"webhook" => Self::Webhook,
"user" => Self::User,
LEGACY_NETWORK_CONFIG_SOURCE => Self::Legacy,
_ => Self::User,
}
}
fn should_update_from_runtime(self, runtime_source: ConfigSource) -> bool {
match (self, runtime_source) {
// Older clients report missing source as `user`, which is not authoritative enough
// to downgrade an existing webhook-owned or legacy row.
(Self::Webhook | Self::Legacy, ConfigSource::User) => false,
_ => self.as_runtime_source() != runtime_source,
}
}
fn as_runtime_source(self) -> ConfigSource {
match self {
Self::User | Self::Legacy => ConfigSource::User,
Self::Webhook => ConfigSource::Webhook,
}
}
fn auto_run_rpc_source(self) -> Option<RpcConfigSource> {
match self {
Self::User => Some(RpcConfigSource::User),
Self::Webhook => Some(RpcConfigSource::Webhook),
Self::Legacy => None,
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Location {
pub country: String,
@@ -148,7 +193,7 @@ impl SessionRpcService {
Ok(serde_json::from_value::<NetworkConfig>(network_config)?)
}
async fn reconcile_managed_network_configs(
async fn reconcile_webhook_source_configs(
storage: &Storage,
user_id: i32,
machine_id: uuid::Uuid,
@@ -159,9 +204,19 @@ impl SessionRpcService {
.list_network_configs((user_id, machine_id), ListNetworkProps::All)
.await
.map_err(|e| anyhow::anyhow!("failed to list existing network configs: {:?}", e))?;
let existing_ids = existing_configs
let existing_sources = existing_configs
.iter()
.filter_map(|cfg| uuid::Uuid::parse_str(&cfg.network_instance_id).ok())
.filter_map(|cfg| {
uuid::Uuid::parse_str(&cfg.network_instance_id)
.ok()
.map(|inst_id| (inst_id, PersistedConfigSource::from_db(&cfg.source)))
})
.collect::<HashMap<_, _>>();
let existing_webhook_ids = existing_sources
.iter()
.filter_map(|(inst_id, source)| {
(*source == PersistedConfigSource::Webhook).then_some(*inst_id)
})
.collect::<HashSet<_>>();
let mut desired_ids = HashSet::with_capacity(desired_configs.len());
@@ -169,10 +224,30 @@ impl SessionRpcService {
for desired in desired_configs {
let inst_id = uuid::Uuid::parse_str(&desired.instance_id).with_context(|| {
format!(
"invalid desired managed instance id: {}",
"invalid desired webhook config instance id: {}",
desired.instance_id
)
})?;
match existing_sources.get(&inst_id) {
Some(PersistedConfigSource::User) => {
tracing::warn!(
?user_id,
?machine_id,
instance_id = %inst_id,
"skip webhook config because a user-owned config already exists"
);
continue;
}
Some(PersistedConfigSource::Legacy) => {
tracing::info!(
?user_id,
?machine_id,
instance_id = %inst_id,
"adopt legacy config as webhook-owned during reconciliation"
);
}
_ => {}
}
let config = Self::normalize_network_config(desired.network_config, inst_id)?;
desired_ids.insert(inst_id);
normalized.insert(inst_id, config);
@@ -181,18 +256,23 @@ impl SessionRpcService {
for (inst_id, config) in normalized {
storage
.db()
.insert_or_update_user_network_config((user_id, machine_id), inst_id, config)
.insert_or_update_user_network_config(
(user_id, machine_id),
inst_id,
config,
ConfigSource::Webhook,
)
.await
.map_err(|e| {
anyhow::anyhow!(
"failed to persist managed network config {}: {:?}",
"failed to persist webhook network config {}: {:?}",
inst_id,
e
)
})?;
}
let stale_ids = existing_ids
let stale_ids = existing_webhook_ids
.difference(&desired_ids)
.copied()
.collect::<Vec<_>>();
@@ -225,7 +305,7 @@ impl SessionRpcService {
let (
user_id,
webhook_managed_network_configs,
webhook_source_configs,
webhook_config_revision,
webhook_validated,
binding_version,
@@ -306,11 +386,11 @@ impl SessionRpcService {
if webhook_validated
&& data.applied_config_revision.as_deref() != Some(webhook_config_revision.as_str())
{
Self::reconcile_managed_network_configs(
Self::reconcile_webhook_source_configs(
&storage,
user_id,
machine_id,
webhook_managed_network_configs,
webhook_source_configs,
)
.await
.map_err(rpc_types::error::Error::from)?;
@@ -396,7 +476,7 @@ pub struct Session {
data: SharedSessionData,
run_network_on_start_task: Option<ScopedTask<()>>,
run_network_on_start_task: Option<AbortOnDropHandle<()>>,
}
impl Debug for Session {
@@ -438,14 +518,134 @@ impl Session {
self.rpc_mgr.run_with_tunnel(tunnel);
let data = self.data.read().await;
self.run_network_on_start_task.replace(
tokio::spawn(Self::run_network_on_start(
data.heartbeat_waiter(),
data.storage.clone(),
self.scoped_rpc_client(),
))
.into(),
);
self.run_network_on_start_task
.replace(AbortOnDropHandle::new(tokio::spawn(
Self::run_network_on_start(
data.heartbeat_waiter(),
data.storage.clone(),
self.scoped_rpc_client(),
),
)));
}
fn collect_webhook_source_instance_ids(
metas: Vec<easytier::proto::api::manage::NetworkMeta>,
) -> HashSet<String> {
metas
.into_iter()
.filter_map(|meta| {
(RpcConfigSource::try_from(meta.source).ok() == Some(RpcConfigSource::Webhook))
.then(|| {
meta.inst_id
.map(|inst_id| Into::<uuid::Uuid>::into(inst_id).to_string())
})
.flatten()
})
.collect()
}
async fn sync_running_config_sources(
db: &crate::db::Db,
user_id: i32,
machine_id: uuid::Uuid,
local_configs: &[crate::db::entity::user_running_network_configs::Model],
metas: &[NetworkMeta],
) -> anyhow::Result<()> {
let local_configs_by_id = local_configs
.iter()
.map(|cfg| (cfg.network_instance_id.clone(), cfg))
.collect::<HashMap<_, _>>();
for meta in metas {
let Some(inst_id) = meta.inst_id.as_ref().map(|inst_id| {
let inst_id: uuid::Uuid = (*inst_id).into();
inst_id
}) else {
continue;
};
let inst_id_str = inst_id.to_string();
let Some(local_cfg) = local_configs_by_id.get(&inst_id_str) else {
continue;
};
let Some(running_source) = ConfigSource::from_rpc(meta.source) else {
continue;
};
let local_source = PersistedConfigSource::from_db(&local_cfg.source);
if !local_source.should_update_from_runtime(running_source) {
continue;
}
db.insert_or_update_user_network_config(
(user_id, machine_id),
inst_id,
local_cfg.get_network_config().map_err(|e| {
anyhow::anyhow!("failed to decode local network config {}: {:?}", inst_id, e)
})?,
running_source,
)
.await
.map_err(|e| {
anyhow::anyhow!(
"failed to sync running network config source {}: {:?}",
inst_id,
e
)
})?;
}
Ok(())
}
async fn repair_legacy_running_config_sources(
db: &crate::db::Db,
user_id: i32,
machine_id: uuid::Uuid,
local_configs: &[crate::db::entity::user_running_network_configs::Model],
) -> anyhow::Result<bool> {
let legacy_configs = local_configs
.iter()
.filter(|cfg| {
PersistedConfigSource::from_db(&cfg.source) == PersistedConfigSource::Legacy
})
.collect::<Vec<_>>();
if legacy_configs.is_empty() {
return Ok(false);
}
for local_cfg in legacy_configs {
let inst_id =
uuid::Uuid::parse_str(&local_cfg.network_instance_id).with_context(|| {
format!(
"failed to parse legacy network config instance id {}",
local_cfg.network_instance_id
)
})?;
db.insert_or_update_user_network_config(
(user_id, machine_id),
inst_id,
local_cfg.get_network_config().map_err(|e| {
anyhow::anyhow!(
"failed to decode legacy network config {}: {:?}",
inst_id,
e
)
})?,
ConfigSource::User,
)
.await
.map_err(|e| {
anyhow::anyhow!(
"failed to repair legacy network config source {}: {:?}",
inst_id,
e
)
})?;
}
Ok(true)
}
async fn run_network_on_start(
@@ -453,8 +653,8 @@ impl Session {
storage: WeakRefStorage,
rpc_client: SessionRpcClient,
) {
let mut cleaned_web_managed_instances = false;
let mut last_desired_inst_ids: Option<HashSet<String>> = None;
let mut cleaned_webhook_source_instances = false;
let mut last_desired_webhook_inst_ids: Option<HashSet<String>> = None;
loop {
heartbeat_waiter = heartbeat_waiter.resubscribe();
let req = heartbeat_waiter.recv().await;
@@ -510,37 +710,160 @@ impl Session {
}
};
let mut local_configs = local_configs;
let running_metas = if req.support_config_source {
let ret = if running_inst_ids.is_empty() {
Ok(Vec::new())
} else {
rpc_client
.list_network_instance_meta(
BaseController::default(),
easytier::proto::api::manage::ListNetworkInstanceMetaRequest {
inst_ids: running_inst_ids
.iter()
.filter_map(|inst_id| uuid::Uuid::parse_str(inst_id).ok())
.map(Into::into)
.collect(),
},
)
.await
.map(|resp| resp.metas)
};
match ret {
Ok(metas) => {
if let Err(e) = Self::sync_running_config_sources(
&storage.db,
user_id,
machine_id.into(),
&local_configs,
&metas,
)
.await
{
tracing::warn!(
?user_id,
?machine_id,
%e,
"Failed to sync running network config sources"
);
} else if !metas.is_empty() {
local_configs = match storage
.db
.list_network_configs(
(user_id, machine_id.into()),
ListNetworkProps::EnabledOnly,
)
.await
{
Ok(configs) => configs,
Err(e) => {
tracing::error!(
"Failed to reload network configs after source sync, error: {:?}",
e
);
return;
}
};
}
Some(metas)
}
Err(e) => {
tracing::warn!(
?user_id,
%e,
"Failed to list running network instance metadata"
);
None
}
}
} else {
None
};
match Self::repair_legacy_running_config_sources(
&storage.db,
user_id,
machine_id.into(),
&local_configs,
)
.await
{
Ok(true) => {
local_configs = match storage
.db
.list_network_configs(
(user_id, machine_id.into()),
ListNetworkProps::EnabledOnly,
)
.await
{
Ok(configs) => configs,
Err(e) => {
tracing::error!(
"Failed to reload network configs after legacy source repair, error: {:?}",
e
);
return;
}
};
}
Ok(false) => {}
Err(e) => {
tracing::warn!(
?user_id,
?machine_id,
%e,
"Failed to repair legacy running network config sources"
);
}
}
let mut has_failed = false;
let should_be_alive_inst_ids = local_configs
let should_be_alive_webhook_inst_ids = local_configs
.iter()
.filter(|cfg| cfg.get_runtime_network_config_source() == ConfigSource::Webhook)
.map(|cfg| cfg.network_instance_id.clone())
.collect::<HashSet<_>>();
let desired_changed = last_desired_inst_ids
let desired_changed = last_desired_webhook_inst_ids
.as_ref()
.is_none_or(|last| last != &should_be_alive_inst_ids);
.is_none_or(|last| last != &should_be_alive_webhook_inst_ids);
if !cleaned_web_managed_instances || desired_changed {
let all_local_configs = match storage
if !cleaned_webhook_source_instances || desired_changed {
let db_webhook_inst_ids = match storage
.db
.list_network_configs((user_id, machine_id.into()), ListNetworkProps::All)
.await
{
Ok(configs) => configs,
Ok(configs) => configs
.iter()
.filter(|cfg| {
cfg.get_runtime_network_config_source() == ConfigSource::Webhook
})
.map(|cfg| cfg.network_instance_id.clone())
.collect::<HashSet<_>>(),
Err(e) => {
tracing::error!("Failed to list all network configs, error: {:?}", e);
return;
}
};
let all_inst_ids = all_local_configs
.iter()
.map(|cfg| cfg.network_instance_id.clone())
let running_webhook_inst_ids = if let Some(metas) = running_metas.as_ref() {
Self::collect_webhook_source_instance_ids(metas.clone())
} else {
running_inst_ids
.intersection(&db_webhook_inst_ids)
.cloned()
.collect()
};
let should_delete_inst_ids = running_webhook_inst_ids
.difference(&should_be_alive_webhook_inst_ids)
.cloned()
.collect::<HashSet<_>>();
let should_delete_ids = running_inst_ids
let should_delete_ids = should_delete_inst_ids
.iter()
.chain(all_inst_ids.iter())
.filter(|inst_id| !should_be_alive_inst_ids.contains(*inst_id))
.filter_map(|inst_id| uuid::Uuid::parse_str(inst_id).ok())
.map(Into::into)
.collect::<Vec<_>>();
@@ -556,7 +879,7 @@ impl Session {
.await;
tracing::info!(
?user_id,
"Clean non-web-managed network instances on start: {:?}, user_token: {:?}",
"Clean stale webhook-source network instances on start: {:?}, user_token: {:?}",
ret,
req.user_token
);
@@ -564,8 +887,8 @@ impl Session {
}
if !has_failed {
cleaned_web_managed_instances = true;
last_desired_inst_ids = Some(should_be_alive_inst_ids.clone());
cleaned_webhook_source_instances = true;
last_desired_webhook_inst_ids = Some(should_be_alive_webhook_inst_ids.clone());
}
}
@@ -573,6 +896,16 @@ impl Session {
if running_inst_ids.contains(&c.network_instance_id) {
continue;
}
let Some(source) = PersistedConfigSource::from_db(&c.source).auto_run_rpc_source()
else {
tracing::warn!(
?user_id,
?machine_id,
instance_id = %c.network_instance_id,
"skip auto-run for legacy config until source ownership is repaired"
);
continue;
};
let ret = rpc_client
.run_network_instance(
BaseController::default(),
@@ -582,6 +915,7 @@ impl Session {
serde_json::from_str::<NetworkConfig>(&c.network_config).unwrap(),
),
overwrite: false,
source: source as i32,
},
)
.await;
@@ -596,7 +930,7 @@ impl Session {
}
if !has_failed {
last_desired_inst_ids = Some(should_be_alive_inst_ids);
last_desired_webhook_inst_ids = Some(should_be_alive_webhook_inst_ids);
}
}
}
@@ -634,13 +968,17 @@ impl Session {
#[cfg(test)]
mod tests {
use easytier::rpc_service::remote_client::{ListNetworkProps, Storage as _};
use easytier::{
common::config::ConfigSource,
rpc_service::remote_client::{ListNetworkProps, PersistentConfig as _, Storage as _},
};
use sea_orm::{ActiveModelTrait, Set};
use serde_json::json;
use super::{super::storage::Storage, *};
#[tokio::test]
async fn reconcile_managed_network_configs_upserts_and_deletes_exact_set() {
async fn reconcile_webhook_source_configs_upserts_and_deletes_exact_set() {
let storage = Storage::new(crate::db::Db::memory_db().await);
let user_id = storage
.db()
@@ -662,6 +1000,7 @@ mod tests {
network_name: Some("old-name".to_string()),
..Default::default()
},
ConfigSource::Webhook,
)
.await
.unwrap();
@@ -674,11 +1013,12 @@ mod tests {
network_name: Some("stale".to_string()),
..Default::default()
},
ConfigSource::Webhook,
)
.await
.unwrap();
SessionRpcService::reconcile_managed_network_configs(
SessionRpcService::reconcile_webhook_source_configs(
&storage,
user_id,
machine_id,
@@ -729,5 +1069,353 @@ mod tests {
updated_keep_config.network_name.as_deref(),
Some("updated-name")
);
assert_eq!(
updated_keep.get_network_config_source(),
ConfigSource::Webhook
);
}
#[tokio::test]
async fn reconcile_webhook_source_configs_keep_user_owned_configs() {
let storage = Storage::new(crate::db::Db::memory_db().await);
let user_id = storage
.db()
.auto_create_user("webhook-user-keep-user")
.await
.unwrap()
.id;
let machine_id = uuid::Uuid::new_v4();
let user_owned_id = uuid::Uuid::new_v4();
let webhook_owned_id = uuid::Uuid::new_v4();
storage
.db()
.insert_or_update_user_network_config(
(user_id, machine_id),
user_owned_id,
NetworkConfig {
network_name: Some("user-owned".to_string()),
..Default::default()
},
ConfigSource::User,
)
.await
.unwrap();
storage
.db()
.insert_or_update_user_network_config(
(user_id, machine_id),
webhook_owned_id,
NetworkConfig {
network_name: Some("webhook-owned".to_string()),
..Default::default()
},
ConfigSource::Webhook,
)
.await
.unwrap();
SessionRpcService::reconcile_webhook_source_configs(
&storage,
user_id,
machine_id,
vec![crate::webhook::ManagedNetworkConfig {
instance_id: user_owned_id.to_string(),
network_config: json!({
"instance_id": user_owned_id.to_string(),
"network_name": "webhook-tries-to-take-over"
}),
}],
)
.await
.unwrap();
let user_owned = storage
.db()
.get_network_config((user_id, machine_id), &user_owned_id.to_string())
.await
.unwrap()
.unwrap();
assert_eq!(user_owned.get_network_config_source(), ConfigSource::User);
let user_owned_cfg: NetworkConfig =
serde_json::from_str(&user_owned.network_config).unwrap();
assert_eq!(user_owned_cfg.network_name.as_deref(), Some("user-owned"));
let webhook_owned = storage
.db()
.get_network_config((user_id, machine_id), &webhook_owned_id.to_string())
.await
.unwrap();
assert!(webhook_owned.is_none());
}
#[tokio::test]
async fn reconcile_webhook_source_configs_adopts_legacy_rows_for_webhook() {
let storage = Storage::new(crate::db::Db::memory_db().await);
let user_id = storage
.db()
.auto_create_user("webhook-user-legacy")
.await
.unwrap()
.id;
let machine_id = uuid::Uuid::new_v4();
let legacy_match_id = uuid::Uuid::new_v4();
let legacy_user_id = uuid::Uuid::new_v4();
crate::db::entity::user_running_network_configs::ActiveModel {
user_id: Set(user_id),
device_id: Set(machine_id.to_string()),
network_instance_id: Set(legacy_match_id.to_string()),
network_config: Set(serde_json::to_string(&NetworkConfig {
network_name: Some("legacy-webhook".to_string()),
..Default::default()
})
.unwrap()),
source: Set(LEGACY_NETWORK_CONFIG_SOURCE.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(storage.db().orm_db())
.await
.unwrap();
crate::db::entity::user_running_network_configs::ActiveModel {
user_id: Set(user_id),
device_id: Set(machine_id.to_string()),
network_instance_id: Set(legacy_user_id.to_string()),
network_config: Set(serde_json::to_string(&NetworkConfig {
network_name: Some("legacy-user".to_string()),
..Default::default()
})
.unwrap()),
source: Set(LEGACY_NETWORK_CONFIG_SOURCE.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(storage.db().orm_db())
.await
.unwrap();
SessionRpcService::reconcile_webhook_source_configs(
&storage,
user_id,
machine_id,
vec![crate::webhook::ManagedNetworkConfig {
instance_id: legacy_match_id.to_string(),
network_config: json!({
"instance_id": legacy_match_id.to_string(),
"network_name": "managed-by-webhook"
}),
}],
)
.await
.unwrap();
let adopted = storage
.db()
.get_network_config((user_id, machine_id), &legacy_match_id.to_string())
.await
.unwrap()
.unwrap();
assert_eq!(adopted.source, ConfigSource::Webhook.as_str());
let adopted_cfg: NetworkConfig = serde_json::from_str(&adopted.network_config).unwrap();
assert_eq!(
adopted_cfg.network_name.as_deref(),
Some("managed-by-webhook")
);
let untouched_legacy = storage
.db()
.get_network_config((user_id, machine_id), &legacy_user_id.to_string())
.await
.unwrap()
.unwrap();
assert_eq!(untouched_legacy.source, LEGACY_NETWORK_CONFIG_SOURCE);
}
#[tokio::test]
async fn sync_running_config_sources_updates_enabled_config_source_from_runtime() {
let storage = Storage::new(crate::db::Db::memory_db().await);
let user_id = storage
.db()
.auto_create_user("webhook-user-sync-source")
.await
.unwrap()
.id;
let machine_id = uuid::Uuid::new_v4();
let inst_id = uuid::Uuid::new_v4();
storage
.db()
.insert_or_update_user_network_config(
(user_id, machine_id),
inst_id,
NetworkConfig {
network_name: Some("webhook-owned".to_string()),
..Default::default()
},
ConfigSource::Webhook,
)
.await
.unwrap();
let local_configs = storage
.db()
.list_network_configs((user_id, machine_id), ListNetworkProps::EnabledOnly)
.await
.unwrap();
Session::sync_running_config_sources(
storage.db(),
user_id,
machine_id,
&local_configs,
&[easytier::proto::api::manage::NetworkMeta {
inst_id: Some(inst_id.into()),
source: RpcConfigSource::User as i32,
..Default::default()
}],
)
.await
.unwrap();
let updated = storage
.db()
.get_network_config((user_id, machine_id), &inst_id.to_string())
.await
.unwrap()
.unwrap();
assert_eq!(updated.get_network_config_source(), ConfigSource::Webhook);
}
#[tokio::test]
async fn sync_running_config_sources_keeps_legacy_rows_when_runtime_source_is_user() {
let storage = Storage::new(crate::db::Db::memory_db().await);
let user_id = storage
.db()
.auto_create_user("webhook-user-sync-legacy")
.await
.unwrap()
.id;
let machine_id = uuid::Uuid::new_v4();
let inst_id = uuid::Uuid::new_v4();
crate::db::entity::user_running_network_configs::ActiveModel {
user_id: Set(user_id),
device_id: Set(machine_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_NETWORK_CONFIG_SOURCE.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(storage.db().orm_db())
.await
.unwrap();
let local_configs = storage
.db()
.list_network_configs((user_id, machine_id), ListNetworkProps::EnabledOnly)
.await
.unwrap();
Session::sync_running_config_sources(
storage.db(),
user_id,
machine_id,
&local_configs,
&[easytier::proto::api::manage::NetworkMeta {
inst_id: Some(inst_id.into()),
source: RpcConfigSource::User as i32,
..Default::default()
}],
)
.await
.unwrap();
let updated = storage
.db()
.get_network_config((user_id, machine_id), &inst_id.to_string())
.await
.unwrap()
.unwrap();
assert_eq!(updated.source, LEGACY_NETWORK_CONFIG_SOURCE);
}
#[tokio::test]
async fn repair_legacy_running_config_sources_promotes_remaining_legacy_rows_to_user() {
let storage = Storage::new(crate::db::Db::memory_db().await);
let user_id = storage
.db()
.auto_create_user("webhook-user-repair-legacy")
.await
.unwrap()
.id;
let machine_id = uuid::Uuid::new_v4();
let inst_id = uuid::Uuid::new_v4();
crate::db::entity::user_running_network_configs::ActiveModel {
user_id: Set(user_id),
device_id: Set(machine_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_NETWORK_CONFIG_SOURCE.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(storage.db().orm_db())
.await
.unwrap();
let local_configs = storage
.db()
.list_network_configs((user_id, machine_id), ListNetworkProps::EnabledOnly)
.await
.unwrap();
assert!(
Session::repair_legacy_running_config_sources(
storage.db(),
user_id,
machine_id,
&local_configs,
)
.await
.unwrap()
);
let updated = storage
.db()
.get_network_config((user_id, machine_id), &inst_id.to_string())
.await
.unwrap()
.unwrap();
assert_eq!(updated.source, ConfigSource::User.as_str());
}
#[test]
fn legacy_configs_are_not_auto_run_until_repaired() {
assert_eq!(PersistedConfigSource::Legacy.auto_run_rpc_source(), None);
assert_eq!(
PersistedConfigSource::Webhook.auto_run_rpc_source(),
Some(RpcConfigSource::Webhook)
);
assert_eq!(
PersistedConfigSource::User.auto_run_rpc_source(),
Some(RpcConfigSource::User)
);
}
}
@@ -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)
}
}
+73 -8
View File
@@ -3,6 +3,7 @@
pub mod entity;
use easytier::{
common::config::ConfigSource,
launcher::NetworkConfig,
rpc_service::remote_client::{ListNetworkProps, Storage},
};
@@ -149,6 +150,7 @@ 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?;
@@ -161,6 +163,7 @@ impl Storage<(UserIdInDb, Uuid), user_running_network_configs::Model, DbErr> for
])
.update_columns([
urnc::Column::NetworkConfig,
urnc::Column::Source,
urnc::Column::Disabled,
urnc::Column::UpdateTime,
])
@@ -172,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()),
@@ -277,8 +281,12 @@ 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::{Db, ListNetworkProps, entity::user_running_network_configs};
@@ -294,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))
@@ -306,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 {
@@ -313,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))
@@ -325,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);
@@ -348,6 +372,45 @@ mod tests {
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;
@@ -363,6 +426,7 @@ mod tests {
network_name: Some("cfg-1".to_string()),
..Default::default()
},
ConfigSource::User,
)
.await
.unwrap();
@@ -373,6 +437,7 @@ mod tests {
network_name: Some("cfg-2".to_string()),
..Default::default()
},
ConfigSource::User,
)
.await
.unwrap();
@@ -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(())
}
}
+2
View File
@@ -2,6 +2,7 @@ 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;
@@ -11,6 +12,7 @@ impl MigratorTrait for Migrator {
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),
]
}
}
+10 -13
View File
@@ -17,12 +17,12 @@ use axum_login::tower_sessions::{ExpiredDeletion, SessionManagerLayer};
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 tokio_util::task::AbortOnDropHandle;
use tower_sessions::Expiry;
use tower_sessions::cookie::time::Duration;
use tower_sessions::cookie::{Key, SameSite};
@@ -199,8 +199,8 @@ impl RestfulServer {
mut self,
) -> Result<
(
ScopedTask<()>,
ScopedTask<tower_sessions::session_store::Result<()>>,
AbortOnDropHandle<()>,
AbortOnDropHandle<tower_sessions::session_store::Result<()>>,
),
anyhow::Error,
> {
@@ -213,13 +213,11 @@ 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();
@@ -298,10 +296,9 @@ 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))
}
+5 -6
View File
@@ -6,10 +6,10 @@ use axum::{
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)]
@@ -59,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 {
@@ -71,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)
}
+30 -26
View File
@@ -3,7 +3,7 @@ name = "easytier"
description = "A full meshed p2p VPN, connecting all your devices in one network with one command."
homepage = "https://github.com/EasyTier/EasyTier"
repository = "https://github.com/EasyTier/EasyTier"
version = "2.6.0"
version = "2.6.4"
edition.workspace = true
rust-version.workspace = true
authors = ["kkrainbow"]
@@ -11,6 +11,7 @@ keywords = ["vpn", "p2p", "network", "easytier"]
categories = ["network-programming", "command-line-utilities"]
license-file = "LICENSE"
readme = "README.md"
build = "build/main.rs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -50,26 +51,34 @@ time = "0.3"
toml = "0.8.12"
chrono = { version = "0.4.37", features = ["serde"] }
getset = "0.1.6"
optionize = "0.2"
guarden = "0.1"
delegate = "0.13.5"
itertools = "0.14.0"
strum = { version = "0.27.2", features = ["derive"] }
gethostname = "0.5.0"
hostname = "0.4.2"
futures = { version = "0.3", features = ["bilock", "unstable"] }
tokio = { version = "1", features = ["full"] }
tokio-stream = "0.1"
tokio-util = { version = "0.7.9", features = ["codec", "net", "io"] }
tokio-util = { version = "0.7.9", features = ["codec", "net", "io", "rt"] }
async-stream = "0.3.5"
async-trait = "0.1.74"
maplit = "1.0.2"
dashmap = "6.0"
timedmap = "=1.0.1"
moka = { version = "0.12", features = ["future"] }
# for full-path zero-copy
zerocopy = { version = "0.7.32", features = ["derive", "simd"] }
bytes = "1.5.0"
@@ -148,6 +157,7 @@ rand = "0.8.5"
serde = { version = "1.0", features = ["derive"] }
pnet = { version = "0.35.0", features = ["serde"] }
serde_json = "1"
serde_with = "3"
clap = { version = "4.5.30", features = [
"string",
@@ -166,6 +176,7 @@ network-interface = "2.0"
# for ospf route
petgraph = "0.8.1"
ordered_hash_map = "0.5.0"
indexmap = "2.13.1"
# for wireguard
boringtun = { package = "boringtun-easytier", version = "0.6.1", optional = true }
@@ -232,17 +243,14 @@ http_req = { git = "https://github.com/EasyTier/http_req.git", default-features
] }
# for dns connector
hickory-resolver = "0.25.2"
hickory-proto = "0.25.2"
hickory-proto = "0.26.0"
hickory-net = { version = "0.26.0", features = ["serde"] }
hickory-resolver = { version = "0.26.0", features = ["https-ring", "webpki-roots"] }
# for magic dns
hickory-client = { version = "0.25.2", optional = true }
hickory-server = { version = "0.25.2", features = [
"resolver",
], optional = true }
hickory-server = { version = "0.26.0", features = ["resolver"], optional = true }
bon = "3.9.1"
derive_builder = "0.20.2"
humantime-serde = "1.1.1"
multimap = "0.10.1"
version-compare = "0.2.0"
@@ -252,6 +260,8 @@ shellexpand = "3.1.1"
# for fake tcp
flume = { version = "0.12", optional = true }
igd-next = { version = "0.17.0", features = ["aio_tokio"] }
natpmp = "0.5.0"
[target.'cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", target_os = "freebsd"))'.dependencies]
machine-uid = "0.5.3"
@@ -272,11 +282,15 @@ windivert = { git = "https://github.com/EasyTier/windivert-rust.git", rev = "adc
] }
[target.'cfg(windows)'.dependencies]
windows = { version = "0.52.0", features = [
windows = { version = "0.62.2", features = [
"Win32_Foundation",
"Win32_NetworkManagement_IpHelper",
"Win32_NetworkManagement_Ndis",
"Win32_NetworkManagement_WindowsFirewall",
"Win32_System_Com",
"Win32_Networking",
"Win32_System_Com",
"Win32_System_Diagnostics",
"Win32_System_Diagnostics_Debug",
"Win32_System_Ole",
"Win32_System_Variant",
"Win32_Networking_WinSock",
@@ -285,14 +299,6 @@ windows = { version = "0.52.0", features = [
encoding = "0.2"
winreg = "0.52"
windows-service = "0.7.0"
windows-sys = { version = "0.52", features = [
"Win32_NetworkManagement_IpHelper",
"Win32_NetworkManagement_Ndis",
"Win32_Networking_WinSock",
"Win32_Foundation",
"Win32_System_Diagnostics",
"Win32_System_Diagnostics_Debug",
] }
winapi = { version = "0.3.9", features = ["impl-default"] }
[target.'cfg(not(windows))'.dependencies]
@@ -315,15 +321,14 @@ jemalloc-sys = { package = "tikv-jemalloc-sys", version = "0.6.0", features = [
[build-dependencies]
cfg_aliases = "0.2.1"
tonic-build = "0.12"
indoc = "2.0"
globwalk = "0.8.1"
regex = "1"
prost-build = "0.13.5"
prost-wkt-build = "0.6"
easytier-rpc-build = { path = "../easytier-rpc-build", features = [
"internal-namespace",
] }
prost-reflect-build = { version = "0.14.0" }
proc-macro2 = "1"
quote = "1"
thunk-rs = { git = "https://github.com/easytier/thunk.git", default-features = false, features = [
"win7",
] }
@@ -337,7 +342,6 @@ zip = "4.0.0"
serial_test = "3.0.0"
rstest = "0.25.0"
futures-util = "0.3.31"
maplit = "1.0.2"
tempfile = "3.22.0"
ctor = "0.8.0"
@@ -399,7 +403,7 @@ jemalloc-prof = [
"jemalloc-sys/stats",
]
tracing = ["tokio/tracing", "dep:console-subscriber"]
magic-dns = ["dep:hickory-client", "dep:hickory-server"]
magic-dns = ["dep:hickory-server"]
faketcp = ["dep:flume"]
zstd = ["dep:zstd"]
# For Network Extension on macOS
+28 -15
View File
@@ -1,3 +1,6 @@
mod rpc;
use crate::rpc::ServiceGenerator;
use cfg_aliases::cfg_aliases;
use prost_wkt_build::{FileDescriptorSet, Message as _};
#[cfg(target_os = "windows")]
@@ -163,7 +166,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
"src/proto/api_config.proto",
"src/proto/api_manage.proto",
"src/proto/web.proto",
"src/proto/magic_dns.proto",
"src/proto/dns.proto",
"src/proto/acl.proto",
];
@@ -171,17 +174,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("cargo:rerun-if-changed={proto_file}");
}
let out = PathBuf::from(env::var("OUT_DIR").unwrap());
let descriptor_file = out.join("descriptors.bin");
let mut config = prost_build::Config::new();
config
.type_attribute(".", "#[derive(serde::Serialize,serde::Deserialize)]")
.extern_path(".google.protobuf.Any", "::prost_wkt_types::Any")
.extern_path(".google.protobuf.Timestamp", "::prost_wkt_types::Timestamp")
.extern_path(".google.protobuf.Value", "::prost_wkt_types::Value")
.file_descriptor_set_path(&descriptor_file)
.protoc_arg("--experimental_allow_proto3_optional")
.extern_path(".google.protobuf.Value", "::prost_wkt_types::Value");
config
.type_attribute(".", "#[derive(serde::Serialize,serde::Deserialize)]")
.type_attribute("peer_rpc.DirectConnectedPeerInfo", "#[derive(Hash)]")
.type_attribute("peer_rpc.PeerInfoForGlobalMap", "#[derive(Hash)]")
.type_attribute("peer_rpc.ForeignNetworkRouteInfoKey", "#[derive(Hash, Eq)]")
@@ -191,19 +191,32 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
)
.type_attribute("peer_rpc.RouteForeignNetworkSummary", "#[derive(Hash, Eq)]")
.type_attribute("common.RpcDescriptor", "#[derive(Hash, Eq)]")
.field_attribute(".api.manage.NetworkConfig", "#[serde(default)]")
.service_generator(Box::new(easytier_rpc_build::ServiceGenerator::default()))
.btree_map(["."])
.skip_debug([".common.Ipv4Addr", ".common.Ipv6Addr", ".common.UUID"]);
.type_attribute("acl.Acl", "#[serde(default)]")
.type_attribute("acl.AclV1", "#[serde(default)]")
.type_attribute("acl.Chain", "#[serde(default)]")
.type_attribute("acl.Rule", "#[serde(default)]")
.type_attribute("acl.GroupInfo", "#[serde(default)]");
config.compile_protos(&proto_files, &["src/proto/"])?;
config.field_attribute("api.manage.NetworkConfig", "#[serde(default)]");
config.skip_debug([".common.Ipv4Addr", ".common.Ipv6Addr", ".common.UUID"]);
let out = PathBuf::from(env::var("OUT_DIR")?);
let descriptor_file = out.join("descriptors.bin");
config
.btree_map(["."])
.service_generator(Box::new(ServiceGenerator::default()))
.protoc_arg("--experimental_allow_proto3_optional")
.file_descriptor_set_path(&descriptor_file)
.compile_protos(&proto_files, &["src/proto/"])?;
prost_reflect_build::Builder::new()
.file_descriptor_set_bytes("crate::proto::DESCRIPTOR_POOL_BYTES")
.compile_protos_with_config(config, &proto_files_reflect, &["src/proto/"])?;
let descriptor_bytes = std::fs::read(descriptor_file).unwrap();
let descriptor = FileDescriptorSet::decode(&descriptor_bytes[..]).unwrap();
let descriptor_bytes = std::fs::read(descriptor_file)?;
let descriptor = FileDescriptorSet::decode(&descriptor_bytes[..])?;
prost_wkt_build::add_serde(out, descriptor);
check_locale();
+720
View File
@@ -0,0 +1,720 @@
#![allow(non_snake_case)]
use indoc::formatdoc;
use proc_macro2::{Ident, TokenStream};
use quote::{format_ident, quote};
use std::str::FromStr;
fn parse(value: &str) -> TokenStream {
TokenStream::from_str(value)
.unwrap_or_else(|err| panic!("Failed to parse tokens: {} ({})", value, err))
}
fn doc(comments: &prost_build::Comments) -> TokenStream {
let doc = comments
.leading
.iter()
.flat_map(|c| c.lines().filter(|s| !s.is_empty()));
quote! { #( #[doc = #doc] )* }
}
const NAMESPACE: &str = "crate::proto::rpc_types";
struct Method {
index: u8,
doc: TokenStream,
method: Ident,
method_inner: Ident,
method_str: String,
method_proto: Ident,
method_proto_str: String,
Input: TokenStream,
Input_proto_str: String,
Output: TokenStream,
Output_proto_str: String,
}
impl Method {
fn new(index: u8, method: prost_build::Method) -> Self {
assert!(
!method.client_streaming,
"Client streaming not yet supported for method {}",
method.proto_name
);
assert!(
!method.server_streaming,
"Server streaming not yet supported for method {}",
method.proto_name
);
Self {
index,
doc: doc(&method.comments),
method: format_ident!("{}", method.name),
method_inner: format_ident!("{}_inner", method.name),
method_str: method.name,
method_proto: format_ident!("{}", method.proto_name),
method_proto_str: method.proto_name,
Input: parse(&method.input_type),
Input_proto_str: method.input_proto_type,
Output: parse(&method.output_type),
Output_proto_str: method.output_proto_type,
}
}
}
struct Service {
namespace: TokenStream,
doc: TokenStream,
Service: Ident,
ServiceDescriptor: Ident,
ServiceServer: Ident,
ServiceClient: Ident,
ServiceClientFactory: Ident,
ServiceMethodDescriptor: Ident,
Service_str: String,
Service_proto_str: String,
Service_package_str: String,
methods: Vec<Method>,
}
impl Service {
fn new(service: prost_build::Service) -> Self {
let methods = service
.methods
.into_iter()
.enumerate()
.map(|(i, method)| Method::new((i + 1) as u8, method))
.collect();
Self {
namespace: parse(NAMESPACE),
doc: doc(&service.comments),
Service: format_ident!("{}", service.name),
ServiceDescriptor: format_ident!("{}Descriptor", service.name),
ServiceServer: format_ident!("{}Server", service.name),
ServiceClient: format_ident!("{}Client", service.name),
ServiceClientFactory: format_ident!("{}ClientFactory", service.name),
ServiceMethodDescriptor: format_ident!("{}MethodDescriptor", service.name),
Service_str: service.name,
Service_proto_str: service.proto_name,
Service_package_str: service.package,
methods,
}
}
fn trait_Service(&self) -> TokenStream {
let Self {
namespace,
doc,
Service,
methods,
..
} = self;
let match_json_call_method = methods.iter().map(
|Method {
method,
method_str,
method_proto_str,
Input,
..
}| {
quote! {
#method_str | #method_proto_str => {
let req: #Input = ::serde_json::from_value(json)
.map_err(|e| #namespace::error::Error::MalformatRpcPacket(format!("json error: {}", e)))?;
let resp = self.#method(ctrl, req).await?;
Ok(::serde_json::to_value(resp)
.map_err(|e| #namespace::error::Error::MalformatRpcPacket(format!("json error: {}", e)))?)
}
}
},
);
let methods = methods.iter().map(
|Method {
doc,
method,
Input,
Output,
..
}| {
quote! {
#doc
async fn #method(&self, ctrl: Self::Controller, input: #Input) -> #namespace::error::Result<#Output>;
}
},
);
quote! {
#doc
#[async_trait::async_trait]
#[auto_impl::auto_impl(&, Arc, Box)]
pub trait #Service {
type Controller: #namespace::controller::Controller;
#(#methods)*
async fn json_call_method(
&self,
ctrl: Self::Controller,
method: &str,
json: ::serde_json::Value,
) -> #namespace::error::Result<::serde_json::Value> {
match method {
#(#match_json_call_method)*
_ => Err(#namespace::error::Error::InvalidMethodIndex(0, method.to_string())),
}
}
}
}
}
fn impl_Service_for_Weak(&self) -> TokenStream {
let Self {
namespace,
Service,
methods,
..
} = self;
let methods = methods.iter().map(
|Method {
method,
Input,
Output,
..
}| {
quote! {
async fn #method(&self, ctrl: Self::Controller, input: #Input) -> #namespace::error::Result<#Output> {
let Some(service) = self.upgrade() else {
return Err(#namespace::error::Error::Shutdown);
};
service.#method(ctrl, input).await
}
}
},
);
quote! {
#[async_trait::async_trait]
impl<T> #Service for ::std::sync::Weak<T>
where
T: Send + Sync + 'static,
::std::sync::Arc<T>: #Service,
{
type Controller = <::std::sync::Arc<T> as #Service>::Controller;
#(#methods)*
}
}
}
fn struct_ServiceDescriptor(&self) -> TokenStream {
let Self {
namespace,
ServiceDescriptor,
ServiceMethodDescriptor,
Service_str,
Service_proto_str,
Service_package_str,
methods,
..
} = self;
let doc = format!("A service descriptor for a `{}`.", Service_str);
let methods = methods.iter().map(|Method { method_proto, .. }| {
quote! { #ServiceMethodDescriptor::#method_proto, }
});
quote! {
#[doc = #doc]
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Default)]
pub struct #ServiceDescriptor;
impl #namespace::descriptor::ServiceDescriptor for #ServiceDescriptor {
type Method = #ServiceMethodDescriptor;
fn name(&self) -> &'static str { #Service_str }
fn proto_name(&self) -> &'static str { #Service_proto_str }
fn package(&self) -> &'static str { #Service_package_str }
fn methods(&self) -> &'static [Self::Method] {
&[ #(#methods)* ]
}
}
}
}
fn enum_ServiceMethodDescriptor(&self) -> TokenStream {
let Self {
ServiceMethodDescriptor,
Service_str,
methods,
..
} = self;
let doc = formatdoc! {"
Methods available on a `{Service_str}`.
This can be used as a key when routing requests for servers/clients of a `{Service_str}`.
"};
let variants = methods.iter().map(
|Method {
method_proto,
index,
..
}| {
quote! { #method_proto = #index, }
},
);
let impl_MethodDescriptor = self.impl_MethodDescriptor_for_ServiceMethodDescriptor();
let impl_TryFrom = self.impl_TryFrom_for_ServiceMethodDescriptor();
quote! {
#[doc = #doc]
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
#[repr(u8)]
pub enum #ServiceMethodDescriptor {
#(#variants)*
}
#impl_MethodDescriptor
#impl_TryFrom
}
}
fn impl_MethodDescriptor_for_ServiceMethodDescriptor(&self) -> TokenStream {
let Self {
namespace,
ServiceMethodDescriptor,
methods,
..
} = self;
let name = {
let arms = methods.iter().map(
|Method {
method_proto,
method_str,
..
}| {
quote! { #ServiceMethodDescriptor::#method_proto => #method_str, }
},
);
quote! {
fn name(&self) -> &'static str {
match *self {
#(#arms)*
}
}
}
};
let proto_name = {
let arms = methods.iter().map(
|Method {
method_proto,
method_proto_str,
..
}| {
quote! { #ServiceMethodDescriptor::#method_proto => #method_proto_str, }
},
);
quote! {
fn proto_name(&self) -> &'static str {
match *self {
#(#arms)*
}
}
}
};
let input_type = {
let arms = methods.iter().map(|Method { method_proto, Input, .. }| {
quote! { #ServiceMethodDescriptor::#method_proto => ::std::any::TypeId::of::<#Input>(), }
});
quote! {
fn input_type(&self) -> ::std::any::TypeId {
match *self {
#(#arms)*
}
}
}
};
let input_proto_type = {
let arms = methods.iter().map(
|Method {
method_proto,
Input_proto_str,
..
}| {
quote! { #ServiceMethodDescriptor::#method_proto => #Input_proto_str, }
},
);
quote! {
fn input_proto_type(&self) -> &'static str {
match *self {
#(#arms)*
}
}
}
};
let output_type = {
let arms = methods.iter().map(|Method { method_proto, Output, .. }| {
quote! { #ServiceMethodDescriptor::#method_proto => ::std::any::TypeId::of::<#Output>(), }
});
quote! {
fn output_type(&self) -> ::std::any::TypeId {
match *self {
#(#arms)*
}
}
}
};
let output_proto_type = {
let arms = methods.iter().map(
|Method {
method_proto,
Output_proto_str,
..
}| {
quote! { #ServiceMethodDescriptor::#method_proto => #Output_proto_str, }
},
);
quote! {
fn output_proto_type(&self) -> &'static str {
match *self {
#(#arms)*
}
}
}
};
quote! {
impl #namespace::descriptor::MethodDescriptor for #ServiceMethodDescriptor {
#name
#proto_name
#input_type
#input_proto_type
#output_type
#output_proto_type
fn index(&self) -> u8 {
*self as u8
}
}
}
}
fn impl_TryFrom_for_ServiceMethodDescriptor(&self) -> TokenStream {
let Self {
namespace,
ServiceMethodDescriptor,
Service_str,
methods,
..
} = self;
let arms = methods.iter().map(
|Method {
method_proto,
index,
..
}| {
quote! { #index => Ok(#ServiceMethodDescriptor::#method_proto), }
},
);
quote! {
impl std::convert::TryFrom<u8> for #ServiceMethodDescriptor {
type Error = #namespace::error::Error;
fn try_from(value: u8) -> #namespace::error::Result<Self> {
match value {
#(#arms)*
_ => Err(#namespace::error::Error::InvalidMethodIndex(value, #Service_str.to_string())),
}
}
}
}
}
fn struct_ServiceClient(&self) -> TokenStream {
let Self {
namespace,
ServiceDescriptor,
ServiceClient,
Service_str,
..
} = self;
let doc = formatdoc! {"
A client for a `{Service_str}`.
This implements the `{Service_str}` trait by dispatching all method calls to the supplied `Handler`.
"};
let impl_service_client = self.impl_ServiceClient();
let impl_service_for_client = self.impl_Service_for_ServiceClient();
quote! {
#[doc = #doc]
#[derive(Clone, Debug)]
pub struct #ServiceClient<H>(H) where H: #namespace::handler::Handler;
impl<H> #ServiceClient<H> where H: #namespace::handler::Handler<Descriptor = #ServiceDescriptor> {
/// Creates a new client instance that delegates all method calls to the supplied handler.
pub fn new(handler: H) -> Self {
Self(handler)
}
}
#impl_service_client
#impl_service_for_client
}
}
fn impl_ServiceClient(&self) -> TokenStream {
let Self {
namespace,
ServiceClient,
ServiceDescriptor,
ServiceMethodDescriptor,
methods,
..
} = self;
let methods = methods.iter().map(
|Method {
method_inner,
method_proto,
Input,
Output,
..
}| {
quote! {
async fn #method_inner(handler: H, ctrl: H::Controller, input: #Input) -> #namespace::error::Result<#Output> {
#namespace::__rt::call_method(handler, ctrl, #ServiceMethodDescriptor::#method_proto, input).await
}
}
},
);
quote! {
impl<H> #ServiceClient<H> where H: #namespace::handler::Handler<Descriptor = #ServiceDescriptor> {
#(#methods)*
}
}
}
fn impl_Service_for_ServiceClient(&self) -> TokenStream {
let Self {
namespace,
Service,
ServiceClient,
ServiceDescriptor,
methods,
..
} = self;
let methods = methods.iter().map(
|Method {
method,
method_inner,
Input,
Output,
..
}| {
quote! {
async fn #method(&self, ctrl: H::Controller, input: #Input) -> #namespace::error::Result<#Output> {
#ServiceClient::#method_inner(self.0.clone(), ctrl, input).await
}
}
},
);
quote! {
#[async_trait::async_trait]
impl<H> #Service for #ServiceClient<H> where H: #namespace::handler::Handler<Descriptor = #ServiceDescriptor> {
type Controller = H::Controller;
#(#methods)*
}
}
}
fn struct_ServiceClientFactory(&self) -> TokenStream {
let Self {
namespace,
Service,
ServiceClient,
ServiceClientFactory,
ServiceDescriptor,
..
} = self;
quote! {
pub struct #ServiceClientFactory<C: #namespace::controller::Controller>(std::marker::PhantomData<C>);
impl<C: #namespace::controller::Controller> Clone for #ServiceClientFactory<C> {
fn clone(&self) -> Self {
Self(std::marker::PhantomData)
}
}
impl<C> #namespace::__rt::RpcClientFactory for #ServiceClientFactory<C> where C: #namespace::controller::Controller {
type Descriptor = #ServiceDescriptor;
type ClientImpl = Box<dyn #Service<Controller = C> + Send + Sync + 'static>;
type Controller = C;
fn new(handler: impl #namespace::handler::Handler<Descriptor = Self::Descriptor, Controller = Self::Controller>) -> Self::ClientImpl {
Box::new(#ServiceClient::new(handler))
}
}
}
}
fn struct_ServiceServer(&self) -> TokenStream {
let Self {
namespace,
Service,
ServiceDescriptor,
ServiceServer,
ServiceMethodDescriptor,
Service_str,
methods,
..
} = self;
let doc = formatdoc! {"
A server for a `{Service_str}`.
This implements the `Server` trait by handling requests and dispatch them to methods on the
supplied `{Service_str}`.
"};
let arms = methods.iter().map(
|Method {
method_proto,
method,
Input,
..
}| {
quote! {
#ServiceMethodDescriptor::#method_proto => {
let decoded: #Input = #namespace::__rt::decode(input)?;
let ret = service.#method(ctrl, decoded).await?;
#namespace::__rt::encode(ret)
}
}
},
);
quote! {
#[doc = #doc]
#[derive(Clone, Debug)]
pub struct #ServiceServer<A>(A) where A: #Service + Clone + Send + 'static;
impl<T> #ServiceServer<::std::sync::Weak<T>>
where
T: Send + Sync + 'static,
::std::sync::Arc<T>: #Service,
{
pub fn new_arc(service: ::std::sync::Arc<T>) -> #ServiceServer<::std::sync::Weak<T>> {
#ServiceServer(::std::sync::Arc::downgrade(&service))
}
}
impl<A> #ServiceServer<A> where A: #Service + Clone + Send + 'static {
/// Creates a new server instance that dispatches all calls to the supplied service.
pub fn new(service: A) -> #ServiceServer<A> {
#ServiceServer(service)
}
async fn call_inner(
service: A,
method: #ServiceMethodDescriptor,
ctrl: A::Controller,
input: ::bytes::Bytes)
-> #namespace::error::Result<::bytes::Bytes> {
match method {
#(#arms)*
}
}
}
#[async_trait::async_trait]
impl<A> #namespace::handler::Handler for #ServiceServer<A>
where
A: #Service + Clone + Send + Sync + 'static {
type Descriptor = #ServiceDescriptor;
type Controller = A::Controller;
async fn call(
&self,
ctrl: A::Controller,
method: #ServiceMethodDescriptor,
input: ::bytes::Bytes)
-> #namespace::error::Result<::bytes::Bytes> {
#ServiceServer::call_inner(self.0.clone(), method, ctrl, input).await
}
}
}
}
}
/// The service generator to be used with `prost-build` to generate RPC implementations for
/// `prost-simple-rpc`.
///
/// See the crate-level documentation for more info.
#[non_exhaustive]
#[derive(Debug, Default)]
pub struct ServiceGenerator;
impl prost_build::ServiceGenerator for ServiceGenerator {
fn generate(&mut self, service: prost_build::Service, buf: &mut String) {
let info = Service::new(service);
let trait_Service = info.trait_Service();
let impl_Service_for_Weak = info.impl_Service_for_Weak();
let struct_ServiceDescriptor = info.struct_ServiceDescriptor();
let enum_ServiceMethodDescriptor = info.enum_ServiceMethodDescriptor();
let struct_ServiceClient = info.struct_ServiceClient();
let struct_ServiceClientFactory = info.struct_ServiceClientFactory();
let struct_ServiceServer = info.struct_ServiceServer();
let tokens = quote! {
#trait_Service
#impl_Service_for_Weak
#struct_ServiceDescriptor
#enum_ServiceMethodDescriptor
#struct_ServiceClient
#struct_ServiceClientFactory
#struct_ServiceServer
};
buf.push('\n');
buf.push_str(&tokens.to_string());
buf.push('\n');
}
}
+20 -8
View File
@@ -12,9 +12,9 @@ core_clap:
仅用户名:--config-server admin,将使用官方的服务器
machine_id:
en: |+
the machine id to identify this machine, used for config recovery after disconnection, must be unique and fixed. default is from system.
the machine id to identify this machine, used for config recovery after disconnection, must be unique and fixed. by default it is loaded from persisted local state; on first start it may be migrated from system information or generated, then remains fixed.
zh-CN: |+
Web 配置服务器通过 machine id 来识别机器,用于断线重连后的配置恢复,需要保证唯一且固定不变。默认从系统获得
Web 配置服务器通过 machine id 来识别机器,用于断线重连后的配置恢复,需要保证唯一且固定不变。默认从本地持久化状态读取;首次启动时可能基于系统信息迁移或生成,之后保持固定不变
config_file:
en: "path to the config file, NOTE: the options set by cmdline args will override options in config file"
zh-CN: "配置文件路径,注意:命令行中的配置的选项会覆盖配置文件中的选项"
@@ -39,6 +39,15 @@ core_clap:
ipv6:
en: "ipv6 address of this vpn node, can be used together with ipv4 for dual-stack operation"
zh-CN: "此VPN节点的IPv6地址,可与IPv4一起使用以进行双栈操作"
ipv6_public_addr_provider:
en: "share this node's public IPv6 subnet with other peers so they can obtain public IPv6 addresses (Linux only)"
zh-CN: "将此节点的公网 IPv6 子网共享给其他节点,使它们也能获得公网 IPv6 地址(仅 Linux 支持)"
ipv6_public_addr_auto:
en: "auto-obtain a public IPv6 address from a peer that shares its IPv6 subnet"
zh-CN: "自动从共享了 IPv6 子网的对等节点获取一个公网 IPv6 地址"
ipv6_public_addr_prefix:
en: "manually specify the public IPv6 subnet to share, instead of auto-detecting from system routes"
zh-CN: "手动指定要共享的公网 IPv6 子网,不自动从系统路由检测"
dhcp:
en: "automatically determine and set IP address by Easytier, and the IP address starts from 10.0.0.1 by default. Warning, if there is an IP conflict in the network when using DHCP, the IP will be automatically changed."
zh-CN: "由Easytier自动确定并设置IP地址,默认从10.0.0.1开始。警告:在使用DHCP时,如果网络中出现IP冲突,IP将自动更改。"
@@ -172,6 +181,12 @@ core_clap:
disable_sym_hole_punching:
en: "if true, disable udp nat hole punching for symmetric nat (NAT4), which is based on birthday attack and may be blocked by ISP."
zh-CN: "如果为true,则禁用基于生日攻击的对称NAT (NAT4) UDP 打洞功能,该打洞方式可能会被运营商封锁"
disable_upnp:
en: "disable runtime UPnP/NAT-PMP port mapping for eligible listeners; automatic port mapping is enabled by default"
zh-CN: "禁用符合条件监听器的运行时 UPnP/NAT-PMP 端口映射;自动端口映射默认开启"
enable_udp_broadcast_relay:
en: "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."
zh-CN: "仅 Windows:捕获物理网卡上的本机 UDP 广播包并转发给 EasyTier 对等节点,帮助局域网游戏发现房间。需要管理员权限。"
relay_all_peer_rpc:
en: "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."
zh-CN: "转发所有对等节点的RPC数据包,即使对等节点不在转发网络白名单中。这可以帮助白名单外网络中的对等节点建立P2P连接。"
@@ -205,12 +220,6 @@ core_clap:
port_forward:
en: "forward local port to remote port in virtual network. e.g.: udp://0.0.0.0:12345/10.126.126.1:23456, means forward local udp port 12345 to 10.126.126.1:23456 in the virtual network. can specify multiple."
zh-CN: "将本地端口转发到虚拟网络中的远程端口。例如:udp://0.0.0.0:12345/10.126.126.1:23456,表示将本地UDP端口12345转发到虚拟网络中的10.126.126.1:23456。可以指定多个。"
accept_dns:
en: "if true, enable magic dns. with magic dns, you can access other nodes with a domain name, e.g.: <hostname>.et.net. magic dns will modify your system dns settings, enable it carefully."
zh-CN: "如果为true,则启用魔法DNS。使用魔法DNS,您可以使用域名访问其他节点,例如:<hostname>.et.net。魔法DNS将修改您的系统DNS设置,请谨慎启用。"
tld_dns_zone:
en: "specify the top-level domain zone for magic DNS. if not provided, defaults to the value from dns_server module (et.net.). only used when accept_dns is true."
zh-CN: "指定魔法DNS的顶级域名区域。如果未提供,默认使用dns_server模块中的值(et.net.)。仅在accept_dns为true时使用。"
private_mode:
en: "if true, foreign networks are only allowed when this node can verify they use the same network secret, or when a foreign credential node is already trusted via admin-issued credential propagation; different or missing secrets are otherwise rejected."
zh-CN: "如果为true,则仅允许两类 foreign network 接入:本节点能验证其使用相同 network secret 的节点,或已通过 foreign network 管理节点传播而被信任的 credential 节点;否则 secret 不同或缺失时会被拒绝。"
@@ -262,6 +271,9 @@ core_clap:
check_config:
en: Check config validity without starting the network
zh-CN: 检查配置文件的有效性并退出
daemon:
en: Run in daemon mode
zh-CN: 以守护进程模式运行
file_log_size_mb:
en: "per file log size in MB, default is 100MB"
zh-CN: "单个文件日志大小,单位 MB,默认值为 100MB"
+17 -14
View File
@@ -4,7 +4,7 @@ use anyhow::Context;
use network_interface::NetworkInterfaceConfig;
use windows::{
Win32::{
Foundation::{BOOL, FALSE},
Foundation::FALSE,
NetworkManagement::WindowsFirewall::{
INetFwPolicy2, INetFwRule, NET_FW_ACTION_ALLOW, NET_FW_PROFILE2_DOMAIN,
NET_FW_PROFILE2_PRIVATE, NET_FW_PROFILE2_PUBLIC, NET_FW_RULE_DIR_IN,
@@ -20,7 +20,7 @@ use windows::{
System::Ole::{SafeArrayCreateVector, SafeArrayPutElement},
System::Variant::{VARENUM, VARIANT, VT_ARRAY, VT_BSTR, VT_VARIANT},
},
core::BSTR,
core::{BOOL, BSTR},
};
pub fn disable_connection_reset<S: AsRawSocket>(socket: &S) -> io::Result<()> {
@@ -88,13 +88,7 @@ pub fn find_interface_index(iface_name: &str) -> io::Result<u32> {
))
}
pub fn set_ip_unicast_if<S: AsRawSocket>(
socket: &S,
addr: &SocketAddr,
iface: &str,
) -> io::Result<()> {
let handle = SOCKET(socket.as_raw_socket() as usize);
pub fn set_ip_unicast_if(socket: SOCKET, addr: &SocketAddr, iface: &str) -> io::Result<()> {
let if_index = find_interface_index(iface)?;
unsafe {
@@ -103,12 +97,12 @@ pub fn set_ip_unicast_if<S: AsRawSocket>(
SocketAddr::V4(..) => {
let if_index = htonl(if_index);
let if_index_bytes = if_index.to_ne_bytes();
setsockopt(handle, IPPROTO_IP.0, IP_UNICAST_IF, Some(&if_index_bytes))
setsockopt(socket, IPPROTO_IP.0, IP_UNICAST_IF, Some(&if_index_bytes))
}
SocketAddr::V6(..) => {
let if_index_bytes = if_index.to_ne_bytes();
setsockopt(
handle,
socket,
IPPROTO_IPV6.0,
IPV6_UNICAST_IF,
Some(&if_index_bytes),
@@ -141,8 +135,17 @@ pub fn setup_socket_for_win<S: AsRawSocket>(
disable_connection_reset(socket)?;
}
let socket = SOCKET(socket.as_raw_socket() as usize);
// let optval = 1_i32.to_ne_bytes();
// unsafe {
// if setsockopt(socket, SOL_SOCKET, SO_EXCLUSIVEADDRUSE, Some(&optval)) == SOCKET_ERROR {
// return Err(io::Error::last_os_error());
// }
// }
if let Some(iface) = bind_dev {
set_ip_unicast_if(socket, bind_addr, iface.as_str())?;
set_ip_unicast_if(socket, bind_addr, &iface)?;
}
Ok(())
@@ -152,7 +155,7 @@ struct ComInitializer;
impl ComInitializer {
fn new() -> windows::core::Result<Self> {
unsafe { CoInitializeEx(None, COINIT_MULTITHREADED)? };
unsafe { CoInitializeEx(None, COINIT_MULTITHREADED).ok()? };
Ok(Self)
}
}
@@ -354,7 +357,7 @@ fn add_protocol_firewall_rules(
(*interface_variant.Anonymous.Anonymous).vt = VARENUM(VT_ARRAY.0 | VT_VARIANT.0);
(*interface_variant.Anonymous.Anonymous).Anonymous.parray = interface_array;
rule.SetInterfaces(interface_variant)?;
rule.SetInterfaces(&interface_variant)?;
// Get rule collection and add new rule
let rules = policy.Rules()?;
+39
View File
@@ -1339,6 +1339,45 @@ mod tests {
assert_eq!(result.matched_rule, Some(RuleId::Priority(70)));
}
#[tokio::test]
async fn test_forward_acl_source_ip_whitelist() {
let mut acl_config = Acl::default();
let mut acl_v1 = AclV1::default();
let mut chain = Chain {
name: "subnet_proxy_protect".to_string(),
chain_type: ChainType::Forward as i32,
enabled: true,
default_action: Action::Drop as i32,
..Default::default()
};
chain.rules.push(Rule {
name: "allow_my_devices".to_string(),
priority: 1000,
enabled: true,
action: Action::Allow as i32,
protocol: Protocol::Any as i32,
source_ips: vec!["10.172.192.2/32".to_string()],
..Default::default()
});
acl_v1.chains.push(chain);
acl_config.acl_v1 = Some(acl_v1);
let processor = AclProcessor::new(acl_config);
let mut packet_info = create_test_packet_info();
packet_info.dst_ip = "192.168.1.10".parse().unwrap();
packet_info.src_ip = "10.172.192.2".parse().unwrap();
let result = processor.process_packet(&packet_info, ChainType::Forward);
assert_eq!(result.action, Action::Allow);
assert_eq!(result.matched_rule, Some(RuleId::Priority(1000)));
packet_info.src_ip = "10.172.192.3".parse().unwrap();
let result = processor.process_packet(&packet_info, ChainType::Forward);
assert_eq!(result.action, Action::Drop);
assert_eq!(result.matched_rule, Some(RuleId::Default));
}
fn create_test_acl_config() -> Acl {
let mut acl_config = Acl::default();
+540 -44
View File
@@ -1,35 +1,114 @@
use super::env_parser;
use crate::utils::dns;
use crate::{
common::stun::StunInfoCollector,
proto::{
acl::Acl,
api::manage::ConfigSource as RpcConfigSource,
common::{CompressionAlgoPb, PortForwardConfigPb, SecureModeConfig, SocketType},
},
tunnel::{IpScheme, TunnelScheme, generate_digest_from_str},
utils,
};
use anyhow::Context;
use base64::{Engine as _, prelude::BASE64_STANDARD};
use bon::Builder;
use clap::ValueEnum;
use clap::builder::PossibleValue;
use derivative::Derivative;
use derive_more::{Constructor, Deref};
use getset::Getters;
use optionize::Optionized;
use serde::{Deserialize, Serialize};
use std::fmt::{Debug, Display};
use std::{
hash::Hasher,
net::{IpAddr, SocketAddr},
path::PathBuf,
sync::{Arc, Mutex},
};
use anyhow::Context;
use base64::{Engine as _, prelude::BASE64_STANDARD};
use clap::ValueEnum;
use clap::builder::PossibleValue;
use serde::{Deserialize, Serialize};
use strum::{Display, EnumString, VariantArray};
use tokio::io::AsyncReadExt as _;
use crate::{
common::stun::StunInfoCollector,
instance::dns_server::DEFAULT_ET_DNS_ZONE,
proto::{
acl::Acl,
common::{CompressionAlgoPb, PortForwardConfigPb, SecureModeConfig, SocketType},
},
tunnel::generate_digest_from_str,
};
#[derive(Derivative, Debug, Clone, Constructor, Getters, Deref, Deserialize)]
#[derivative(PartialEq(bound = "Parsed: PartialEq"))]
#[serde(try_from = "Raw")]
#[serde(
bound = "Raw: Deserialize<'de>, <ConfigBase<Raw, Parsed, Data> as TryFrom<Raw>>::Error: Display"
)]
pub struct ConfigBase<Raw, Parsed, Data = ()>
where
Raw: Optionized<Subject = Parsed>,
ConfigBase<Raw, Parsed, Data>: TryFrom<Raw>,
{
#[deref]
parsed: Parsed,
#[getset(get)]
#[derivative(PartialEq = "ignore")]
raw: Raw,
#[getset(get)]
#[derivative(PartialEq = "ignore")]
data: Data,
}
use super::env_parser;
impl<Raw, Parsed, Data> Serialize for ConfigBase<Raw, Parsed, Data>
where
Raw: Optionized<Subject = Parsed> + Serialize,
ConfigBase<Raw, Parsed, Data>: TryFrom<Raw, Error: Debug>,
{
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
self.raw.serialize(serializer)
}
}
impl<Raw, Parsed, Data> Default for ConfigBase<Raw, Parsed, Data>
where
Raw: Optionized<Subject = Parsed> + Default,
ConfigBase<Raw, Parsed, Data>: TryFrom<Raw, Error: Debug>,
{
fn default() -> Self {
Raw::default().try_into().unwrap()
}
}
impl<Raw, Parsed, Data> ConfigBase<Raw, Parsed, Data>
where
Raw: Optionized<Subject = Parsed>,
ConfigBase<Raw, Parsed, Data>: TryFrom<Raw, Error: Debug>,
{
pub fn into_parsed(self) -> Parsed {
self.parsed
}
pub fn into_raw(self) -> Raw {
self.raw
}
pub fn into_data(self) -> Data {
self.data
}
pub fn update(self, config: Raw) -> Result<Self, <Self as TryFrom<Raw>>::Error> {
let mut raw = self.into_raw();
raw.merge(config);
raw.try_into()
}
}
pub type Flags = crate::proto::common::FlagsInConfig;
pub fn gen_default_flags() -> Flags {
#[allow(deprecated)]
Flags {
#[allow(deprecated)]
quic_listen_port: u32::MAX,
#[allow(deprecated)]
accept_dns: false,
#[allow(deprecated)]
tld_dns_zone: "".to_string(),
default_protocol: "tcp".to_string(),
dev_name: "".to_string(),
enable_encryption: true,
@@ -54,7 +133,6 @@ pub fn gen_default_flags() -> Flags {
disable_kcp_input: false,
disable_relay_kcp: false,
enable_relay_foreign_network_kcp: false,
accept_dns: false,
private_mode: false,
enable_quic_proxy: false,
disable_quic_input: false,
@@ -64,14 +142,44 @@ pub fn gen_default_flags() -> Flags {
multi_thread_count: 2,
encryption_algorithm: EncryptionAlgorithm::default().to_string(),
disable_sym_hole_punching: false,
tld_dns_zone: DEFAULT_ET_DNS_ZONE.to_string(),
quic_listen_port: u32::MAX,
need_p2p: false,
instance_recv_bps_limit: u64::MAX,
disable_upnp: false,
disable_relay_data: false,
enable_udp_broadcast_relay: false,
}
}
fn mapped_listener_allows_implicit_port(url: &url::Url) -> bool {
TunnelScheme::try_from(url)
.ok()
.and_then(|scheme| IpScheme::try_from(scheme).ok())
.is_some()
}
pub fn validate_mapped_listener_url(url: &url::Url) -> Result<(), anyhow::Error> {
if url.port().is_none() && !mapped_listener_allows_implicit_port(url) {
anyhow::bail!("mapped listener port is missing: {}", url);
}
Ok(())
}
pub fn parse_mapped_listener_urls(
mapped_listeners: &[String],
) -> Result<Vec<url::Url>, anyhow::Error> {
mapped_listeners
.iter()
.map(|s| {
let url: url::Url = s
.parse()
.with_context(|| format!("mapped listener is not a valid url: {}", s))?;
validate_mapped_listener_url(&url)?;
Ok(url)
})
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq, Display, EnumString, VariantArray)]
#[strum(ascii_case_insensitive)]
pub enum EncryptionAlgorithm {
@@ -118,8 +226,19 @@ impl Default for EncryptionAlgorithm {
}
}
cfg_select! {
feature = "magic-dns" => {
use crate::dns::config::{DnsConfig, DnsConfigLoaderExt};
}
_ => {
#[auto_impl::auto_impl(Box, &)]
pub trait DnsConfigLoaderExt {}
}
}
#[auto_impl::auto_impl(Box, &)]
pub trait ConfigLoader: Send + Sync {
pub trait ConfigLoader: Send + Sync + DnsConfigLoaderExt {
fn get_id(&self) -> uuid::Uuid;
fn set_id(&self, id: uuid::Uuid);
@@ -138,6 +257,15 @@ pub trait ConfigLoader: Send + Sync {
fn get_ipv6(&self) -> Option<cidr::Ipv6Inet>;
fn set_ipv6(&self, addr: Option<cidr::Ipv6Inet>);
fn get_ipv6_public_addr_provider(&self) -> bool;
fn set_ipv6_public_addr_provider(&self, enabled: bool);
fn get_ipv6_public_addr_auto(&self) -> bool;
fn set_ipv6_public_addr_auto(&self, enabled: bool);
fn get_ipv6_public_addr_prefix(&self) -> Option<cidr::Ipv6Cidr>;
fn set_ipv6_public_addr_prefix(&self, prefix: Option<cidr::Ipv6Cidr>);
fn get_dhcp(&self) -> bool;
fn set_dhcp(&self, dhcp: bool);
@@ -197,6 +325,14 @@ pub trait ConfigLoader: Send + Sync {
fn get_stun_servers_v6(&self) -> Option<Vec<String>>;
fn set_stun_servers_v6(&self, servers: Option<Vec<String>>);
fn get_dns_resolvers(&self) -> Vec<String> {
dns::get_default_dns_resolvers()
}
fn get_dns_resolvers_config(&self) -> Option<Vec<String>> {
None
}
fn set_dns_resolvers(&self, _resolvers: Option<Vec<String>>) {}
fn get_secure_mode(&self) -> Option<SecureModeConfig>;
fn set_secure_mode(&self, secure_mode: Option<SecureModeConfig>);
@@ -205,6 +341,11 @@ pub trait ConfigLoader: Send + Sync {
}
fn set_credential_file(&self, _path: Option<std::path::PathBuf>) {}
fn get_network_config_source(&self) -> ConfigSource {
ConfigSource::User
}
fn set_network_config_source(&self, _source: Option<ConfigSource>) {}
fn dump(&self) -> String;
}
@@ -224,6 +365,55 @@ pub struct NetworkIdentity {
pub network_secret_digest: Option<NetworkSecretDigest>,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum ConfigSource {
#[default]
User,
Webhook,
}
impl ConfigSource {
pub fn as_str(self) -> &'static str {
match self {
Self::User => "user",
Self::Webhook => "webhook",
}
}
pub fn from_rpc(source: i32) -> Option<Self> {
match RpcConfigSource::try_from(source).ok() {
Some(RpcConfigSource::Webhook) => Some(Self::Webhook),
Some(RpcConfigSource::User) => Some(Self::User),
_ => None,
}
}
pub fn to_rpc(self) -> i32 {
match self {
Self::User => RpcConfigSource::User as i32,
Self::Webhook => RpcConfigSource::Webhook as i32,
}
}
}
impl std::str::FromStr for ConfigSource {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"user" => Ok(Self::User),
"webhook" => Ok(Self::Webhook),
other => Err(format!("unknown network config source: {other}")),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
struct ConfigSourceConfig {
source: ConfigSource,
}
#[derive(Eq, PartialEq, Hash)]
struct NetworkIdentityWithOnlyDigest {
network_name: String,
@@ -330,11 +520,11 @@ pub struct ConsoleLoggerConfig {
pub level: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, derive_builder::Builder)]
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Builder)]
pub struct LoggingConfig {
#[builder(setter(into, strip_option), default = None)]
#[builder(into)]
pub file_logger: Option<FileLoggerConfig>,
#[builder(setter(into, strip_option), default = None)]
#[builder(into)]
pub console_logger: Option<ConsoleLoggerConfig>,
}
@@ -433,6 +623,9 @@ struct Config {
instance_id: Option<uuid::Uuid>,
ipv4: Option<String>,
ipv6: Option<String>,
ipv6_public_addr_provider: Option<bool>,
ipv6_public_addr_auto: Option<bool>,
ipv6_public_addr_prefix: Option<String>,
dhcp: Option<bool>,
network_identity: Option<NetworkIdentity>,
listeners: Option<Vec<url::Url>>,
@@ -442,6 +635,10 @@ struct Config {
peer: Option<Vec<PeerConfig>>,
proxy_network: Option<Vec<ProxyNetworkConfig>>,
#[cfg(feature = "magic-dns")]
#[serde(default)]
dns: DnsConfig,
vpn_portal_config: Option<VpnPortalConfig>,
routes: Option<Vec<cidr::Ipv4Cidr>>,
@@ -463,8 +660,10 @@ struct Config {
udp_whitelist: Option<Vec<String>>,
stun_servers: Option<Vec<String>>,
stun_servers_v6: Option<Vec<String>>,
dns_resolvers: Option<Vec<String>>,
credential_file: Option<PathBuf>,
source: Option<ConfigSourceConfig>,
}
#[derive(Debug, Clone)]
@@ -479,11 +678,26 @@ impl Default for TomlConfigLoader {
}
impl TomlConfigLoader {
fn normalize_config_source(config: &mut Config) {
if matches!(
config.source.as_ref().map(|source| source.source),
Some(ConfigSource::User)
) {
config.source = None;
}
}
pub fn new_from_str(config_str: &str) -> Result<Self, anyhow::Error> {
let mut config = toml::de::from_str::<Config>(config_str)
.with_context(|| format!("failed to parse config file: {}", config_str))?;
Self::normalize_config_source(&mut config);
config.flags_struct = Some(Self::gen_flags(config.flags.clone().unwrap_or_default()));
if let Some(dns_resolvers) = &config.dns_resolvers {
dns::validate_dns_resolvers(dns_resolvers)
.with_context(|| "invalid dns_resolvers config")?;
}
let config = TomlConfigLoader {
config: Arc::new(Mutex::new(config)),
@@ -525,6 +739,21 @@ impl TomlConfigLoader {
}
}
impl DnsConfigLoaderExt for TomlConfigLoader {
cfg_select! {
feature = "magic-dns" => {
fn get_dns(&self) -> DnsConfig {
self.config.lock().unwrap().dns.clone()
}
fn set_dns(&self, config: DnsConfig) {
self.config.lock().unwrap().dns = config;
}
}
_ => {}
}
}
impl ConfigLoader for TomlConfigLoader {
fn get_inst_name(&self) -> String {
self.config
@@ -540,26 +769,17 @@ impl ConfigLoader for TomlConfigLoader {
}
fn get_hostname(&self) -> String {
let hostname = self.config.lock().unwrap().hostname.clone();
let hostname = self
.config
.lock()
.unwrap()
.hostname
.as_ref()
.map(|hostname| dns::sanitize(hostname))
.filter(|h| !h.is_empty());
match hostname {
Some(hostname) => {
let hostname = hostname
.chars()
.filter(|c| !c.is_control())
.take(32)
.collect::<String>();
if !hostname.is_empty() {
self.set_hostname(Some(hostname.clone()));
hostname
} else {
self.set_hostname(None);
gethostname::gethostname().to_string_lossy().to_string()
}
}
None => gethostname::gethostname().to_string_lossy().to_string(),
}
self.set_hostname(hostname.clone());
hostname.unwrap_or_else(|| utils::dns::sanitize(utils::hostname()))
}
fn set_hostname(&self, name: Option<String>) {
@@ -602,6 +822,43 @@ impl ConfigLoader for TomlConfigLoader {
self.config.lock().unwrap().ipv6 = addr.map(|addr| addr.to_string());
}
fn get_ipv6_public_addr_provider(&self) -> bool {
self.config
.lock()
.unwrap()
.ipv6_public_addr_provider
.unwrap_or_default()
}
fn set_ipv6_public_addr_provider(&self, enabled: bool) {
self.config.lock().unwrap().ipv6_public_addr_provider = Some(enabled);
}
fn get_ipv6_public_addr_auto(&self) -> bool {
self.config
.lock()
.unwrap()
.ipv6_public_addr_auto
.unwrap_or_default()
}
fn set_ipv6_public_addr_auto(&self, enabled: bool) {
self.config.lock().unwrap().ipv6_public_addr_auto = Some(enabled);
}
fn get_ipv6_public_addr_prefix(&self) -> Option<cidr::Ipv6Cidr> {
let locked_config = self.config.lock().unwrap();
locked_config
.ipv6_public_addr_prefix
.as_ref()
.and_then(|s| s.parse().ok())
}
fn set_ipv6_public_addr_prefix(&self, prefix: Option<cidr::Ipv6Cidr>) {
self.config.lock().unwrap().ipv6_public_addr_prefix =
prefix.map(|prefix| prefix.to_string());
}
fn get_dhcp(&self) -> bool {
self.config.lock().unwrap().dhcp.unwrap_or_default()
}
@@ -850,6 +1107,23 @@ impl ConfigLoader for TomlConfigLoader {
self.config.lock().unwrap().stun_servers_v6 = servers;
}
fn get_dns_resolvers(&self) -> Vec<String> {
self.config
.lock()
.unwrap()
.dns_resolvers
.clone()
.unwrap_or_else(dns::get_default_dns_resolvers)
}
fn get_dns_resolvers_config(&self) -> Option<Vec<String>> {
self.config.lock().unwrap().dns_resolvers.clone()
}
fn set_dns_resolvers(&self, resolvers: Option<Vec<String>>) {
self.config.lock().unwrap().dns_resolvers = resolvers;
}
fn get_secure_mode(&self) -> Option<SecureModeConfig> {
self.config.lock().unwrap().secure_mode.clone()
}
@@ -866,6 +1140,23 @@ impl ConfigLoader for TomlConfigLoader {
self.config.lock().unwrap().credential_file = path;
}
fn get_network_config_source(&self) -> ConfigSource {
self.config
.lock()
.unwrap()
.source
.as_ref()
.map(|source| source.source)
.unwrap_or(ConfigSource::User)
}
fn set_network_config_source(&self, source: Option<ConfigSource>) {
self.config.lock().unwrap().source = source.and_then(|source| match source {
ConfigSource::User => None,
other => Some(ConfigSourceConfig { source: other }),
});
}
fn dump(&self) -> String {
let default_flags_json = serde_json::to_string(&gen_default_flags()).unwrap();
let default_flags_hashmap =
@@ -887,6 +1178,7 @@ impl ConfigLoader for TomlConfigLoader {
}
let mut config = self.config.lock().unwrap().clone();
Self::normalize_config_source(&mut config);
config.flags = Some(flag_map);
if config.stun_servers == Some(StunInfoCollector::get_default_servers()) {
config.stun_servers = None;
@@ -894,6 +1186,9 @@ impl ConfigLoader for TomlConfigLoader {
if config.stun_servers_v6 == Some(StunInfoCollector::get_default_servers_v6()) {
config.stun_servers_v6 = None;
}
if config.dns_resolvers == Some(dns::get_default_dns_resolvers()) {
config.dns_resolvers = None;
}
toml::to_string_pretty(&config).unwrap()
}
}
@@ -1125,6 +1420,207 @@ stun_servers = [
assert_eq!(stun_servers[2], "txt:stun.easytier.cn");
}
#[test]
fn test_dns_resolvers_default_and_roundtrip() {
let config = TomlConfigLoader::default();
assert_eq!(config.get_dns_resolvers_config(), None);
assert_eq!(config.get_dns_resolvers(), vec!["system".to_string()]);
assert!(!config.dump().contains("dns_resolvers"));
let config = TomlConfigLoader::new_from_str(
r#"
dns_resolvers = ["system", "https://dns.alidns.com/dns-query"]
"#,
)
.unwrap();
assert_eq!(
config.get_dns_resolvers_config().unwrap(),
vec![
"system".to_string(),
"https://dns.alidns.com/dns-query".to_string()
]
);
assert_eq!(
config.get_dns_resolvers(),
vec![
"system".to_string(),
"https://dns.alidns.com/dns-query".to_string()
]
);
let dumped = config.dump();
assert!(dumped.contains("dns_resolvers"));
let loaded = TomlConfigLoader::new_from_str(&dumped).unwrap();
assert_eq!(loaded.get_dns_resolvers(), config.get_dns_resolvers());
}
#[test]
fn test_dns_resolvers_reject_unknown_doh_without_bootstrap() {
let err = TomlConfigLoader::new_from_str(
r#"
dns_resolvers = ["https://example.com/dns-query"]
"#,
)
.unwrap_err();
assert!(err.to_string().contains("invalid dns_resolvers"));
}
#[test]
fn test_network_config_source_toml_roundtrip() {
let config = TomlConfigLoader::default();
assert_eq!(config.get_network_config_source(), ConfigSource::User);
config.set_network_config_source(Some(ConfigSource::Webhook));
let dumped = config.dump();
assert!(dumped.contains("[source]"));
assert!(dumped.contains("source = \"webhook\""));
let loaded = TomlConfigLoader::new_from_str(&dumped).unwrap();
assert_eq!(loaded.get_network_config_source(), ConfigSource::Webhook);
}
#[test]
fn test_parse_mapped_listener_urls_allows_ws_without_port() {
let parsed = parse_mapped_listener_urls(&[
"ws://example.com".to_string(),
"wss://example.com/path".to_string(),
])
.unwrap();
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[0].scheme(), "ws");
assert_eq!(parsed[0].port(), None);
assert_eq!(parsed[1].scheme(), "wss");
assert_eq!(parsed[1].port(), None);
}
#[test]
fn test_parse_mapped_listener_urls_allows_tcp_without_port() {
let parsed = parse_mapped_listener_urls(&["tcp://127.0.0.1".to_string()]).unwrap();
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].scheme(), "tcp");
assert_eq!(parsed[0].port(), None);
}
#[test]
fn test_parse_mapped_listener_urls_requires_port_for_non_ip_scheme() {
let err = parse_mapped_listener_urls(&["ring://peer-id".to_string()]).unwrap_err();
assert!(err.to_string().contains("mapped listener port is missing"));
}
#[test]
fn test_acl_toml_rule_uses_defaults_for_omitted_fields() {
use crate::proto::acl::{Action, ChainType, Protocol};
let config_str = r#"
[[acl.acl_v1.chains]]
name = "subnet_proxy_protect"
chain_type = 3
enabled = true
default_action = 2
[[acl.acl_v1.chains.rules]]
name = "allow_my_devices"
priority = 1000
action = 1
source_ips = ["10.172.192.2/32"]
protocol = 5
enabled = true
"#;
let config = TomlConfigLoader::new_from_str(config_str).unwrap();
let acl = config.get_acl().unwrap();
let acl_v1 = acl.acl_v1.unwrap();
let chain = &acl_v1.chains[0];
let rule = &chain.rules[0];
assert_eq!(chain.chain_type, ChainType::Forward as i32);
assert_eq!(chain.default_action, Action::Drop as i32);
assert_eq!(rule.action, Action::Allow as i32);
assert_eq!(rule.protocol, Protocol::Any as i32);
assert_eq!(rule.source_ips, vec!["10.172.192.2/32"]);
assert!(rule.ports.is_empty());
assert!(rule.source_ports.is_empty());
assert!(rule.destination_ips.is_empty());
assert!(rule.source_groups.is_empty());
assert!(rule.destination_groups.is_empty());
assert_eq!(rule.rate_limit, 0);
assert_eq!(rule.burst_limit, 0);
assert!(!rule.stateful);
}
#[test]
fn test_acl_toml_group_can_omit_declares_or_members() {
let declares_only = r#"
[acl.acl_v1.group]
[[acl.acl_v1.group.declares]]
group_name = "admin"
group_secret = "admin-pw"
"#;
let config = TomlConfigLoader::new_from_str(declares_only).unwrap();
let group = config.get_acl().unwrap().acl_v1.unwrap().group.unwrap();
assert_eq!(group.declares.len(), 1);
assert!(group.members.is_empty());
let members_only = r#"
[acl.acl_v1.group]
members = ["admin"]
"#;
let config = TomlConfigLoader::new_from_str(members_only).unwrap();
let group = config.get_acl().unwrap().acl_v1.unwrap().group.unwrap();
assert!(group.declares.is_empty());
assert_eq!(group.members, vec!["admin"]);
}
#[test]
fn test_network_config_source_user_is_implicit() {
let config = TomlConfigLoader::default();
config.set_network_config_source(Some(ConfigSource::User));
let dumped = config.dump();
assert!(!dumped.contains("[source]"));
let loaded = TomlConfigLoader::new_from_str(&dumped).unwrap();
assert_eq!(loaded.get_network_config_source(), ConfigSource::User);
let explicit_user = TomlConfigLoader::new_from_str(
r#"
[source]
source = "user"
"#,
)
.unwrap();
assert_eq!(
explicit_user.get_network_config_source(),
ConfigSource::User
);
assert!(!explicit_user.dump().contains("[source]"));
}
#[test]
fn test_ipv6_public_addr_config_roundtrip() {
let config = TomlConfigLoader::default();
let prefix: cidr::Ipv6Cidr = "2001:db8:100::/64".parse().unwrap();
config.set_ipv6_public_addr_provider(true);
config.set_ipv6_public_addr_auto(true);
config.set_ipv6_public_addr_prefix(Some(prefix));
assert!(config.get_ipv6_public_addr_provider());
assert!(config.get_ipv6_public_addr_auto());
assert_eq!(config.get_ipv6_public_addr_prefix(), Some(prefix));
let dumped = config.dump();
let loaded = TomlConfigLoader::new_from_str(&dumped).unwrap();
assert!(loaded.get_ipv6_public_addr_provider());
assert!(loaded.get_ipv6_public_addr_auto());
assert_eq!(loaded.get_ipv6_public_addr_prefix(), Some(prefix));
}
#[tokio::test]
async fn full_example_test() {
let config_str = r#"
-2
View File
@@ -23,8 +23,6 @@ define_global_var!(MANUAL_CONNECTOR_RECONNECT_INTERVAL_MS, u64, 1000);
define_global_var!(OSPF_UPDATE_MY_GLOBAL_FOREIGN_NETWORK_INTERVAL_SEC, u64, 10);
define_global_var!(MACHINE_UID, Option<String>, None);
define_global_var!(MAX_DIRECT_CONNS_PER_PEER_IN_FOREIGN_NETWORK, u32, 3);
define_global_var!(DIRECT_CONNECT_TO_PUBLIC_SERVER, bool, true);
-26
View File
@@ -1,26 +0,0 @@
#[doc(hidden)]
pub struct Defer<F: FnOnce()> {
// internal struct used by defer! macro
func: Option<F>,
}
impl<F: FnOnce()> Defer<F> {
pub fn new(func: F) -> Self {
Self { func: Some(func) }
}
}
impl<F: FnOnce()> Drop for Defer<F> {
fn drop(&mut self) {
if let Some(f) = self.func.take() {
f()
}
}
}
#[macro_export]
macro_rules! defer {
( $($tt:tt)* ) => {
let _deferred = $crate::common::defer::Defer::new(|| { $($tt)* });
};
}
-143
View File
@@ -1,143 +0,0 @@
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use anyhow::Context;
use hickory_proto::runtime::TokioRuntimeProvider;
use hickory_proto::xfer::Protocol;
use hickory_resolver::config::{LookupIpStrategy, NameServerConfig, ResolverConfig, ResolverOpts};
use hickory_resolver::name_server::{GenericConnector, TokioConnectionProvider};
use hickory_resolver::system_conf::read_system_conf;
use hickory_resolver::{Resolver, TokioResolver};
use once_cell::sync::Lazy;
use tokio::net::lookup_host;
use super::error::Error;
pub fn get_default_resolver_config() -> ResolverConfig {
let mut default_resolve_config = ResolverConfig::new();
default_resolve_config.add_name_server(NameServerConfig::new(
"223.5.5.5:53".parse().unwrap(),
Protocol::Udp,
));
default_resolve_config.add_name_server(NameServerConfig::new(
"180.184.1.1:53".parse().unwrap(),
Protocol::Udp,
));
default_resolve_config
}
pub static ALLOW_USE_SYSTEM_DNS_RESOLVER: Lazy<AtomicBool> = Lazy::new(|| AtomicBool::new(true));
pub static RESOLVER: Lazy<Arc<Resolver<GenericConnector<TokioRuntimeProvider>>>> =
Lazy::new(|| {
let system_cfg = read_system_conf();
let mut cfg = get_default_resolver_config();
let mut opt = ResolverOpts::default();
if let Ok(s) = system_cfg {
for ns in s.0.name_servers() {
cfg.add_name_server(ns.clone());
}
opt = s.1;
}
opt.ip_strategy = LookupIpStrategy::Ipv4AndIpv6;
let builder = TokioResolver::builder_with_config(cfg, TokioConnectionProvider::default())
.with_options(opt);
Arc::new(builder.build())
});
pub async fn resolve_txt_record(domain_name: &str) -> Result<String, Error> {
let r = RESOLVER.clone();
let response = r
.txt_lookup(domain_name)
.await
.with_context(|| format!("txt_lookup failed, domain_name: {}", domain_name))?;
let txt_record = response
.iter()
.next()
.with_context(|| format!("no txt record found, domain_name: {}", domain_name))?;
let txt_data = String::from_utf8_lossy(&txt_record.txt_data()[0]);
tracing::info!(?txt_data, ?domain_name, "get txt record");
Ok(txt_data.to_string())
}
pub async fn socket_addrs(
url: &url::Url,
default_port_number: impl Fn() -> Option<u16>,
) -> Result<Vec<SocketAddr>, Error> {
let host = url.host().ok_or(Error::InvalidUrl(url.to_string()))?;
let port = url
.port()
.or_else(default_port_number)
.ok_or(Error::InvalidUrl(url.to_string()))?;
// See https://github.com/EasyTier/EasyTier/pull/947
// here is for compatibility with old version
let port = match port {
0 => match url.scheme() {
"ws" => 80,
"wss" => 443,
_ => port,
},
_ => port,
};
// if host is an ip address, return it directly
match host {
url::Host::Ipv4(ip) => return Ok(vec![SocketAddr::new(std::net::IpAddr::V4(ip), port)]),
url::Host::Ipv6(ip) => return Ok(vec![SocketAddr::new(std::net::IpAddr::V6(ip), port)]),
_ => {}
}
let host = host.to_string();
if ALLOW_USE_SYSTEM_DNS_RESOLVER.load(std::sync::atomic::Ordering::Relaxed) {
let socket_addr = format!("{}:{}", host, port);
match lookup_host(socket_addr).await {
Ok(a) => {
let a = a.collect();
tracing::debug!(?a, "system dns lookup done");
return Ok(a);
}
Err(e) => {
tracing::error!(?e, "system dns lookup failed");
}
}
}
// use hickory_resolver
let ret = RESOLVER.lookup_ip(&host).await.with_context(|| {
format!(
"hickory dns lookup_ip failed, host: {}, port: {}",
host, port
)
})?;
Ok(ret
.iter()
.map(|ip| SocketAddr::new(ip, port))
.collect::<Vec<_>>())
}
#[cfg(test)]
mod tests {
use crate::defer;
use super::*;
#[tokio::test]
async fn test_socket_addrs() {
let url = url::Url::parse("tcp://github-ci-test.easytier.cn:80").unwrap();
let addrs = socket_addrs(&url, || Some(80)).await.unwrap();
assert_eq!(2, addrs.len(), "addrs: {:?}", addrs);
println!("addrs: {:?}", addrs);
ALLOW_USE_SYSTEM_DNS_RESOLVER.store(false, std::sync::atomic::Ordering::Relaxed);
defer!(
ALLOW_USE_SYSTEM_DNS_RESOLVER.store(true, std::sync::atomic::Ordering::Relaxed);
);
let addrs = socket_addrs(&url, || Some(80)).await.unwrap();
assert_eq!(2, addrs.len(), "addrs: {:?}", addrs);
println!("addrs2: {:?}", addrs);
}
}
+2 -1
View File
@@ -1,5 +1,4 @@
use std::{io, result};
use thiserror::Error;
use crate::tunnel;
@@ -55,4 +54,6 @@ pub enum Error {
pub type Result<T> = result::Result<T, Error>;
pub type ErrorCollection = crate::utils::error::ErrorCollection<Error>;
// impl From for std::
+302 -24
View File
@@ -1,14 +1,18 @@
use arc_swap::ArcSwap;
use crossbeam::atomic::AtomicCell;
use dashmap::DashMap;
use hmac::{Hmac, Mac};
use sha2::Sha256;
use socket2::Protocol;
use std::{
collections::{HashMap, hash_map::DefaultHasher},
collections::{BTreeSet, HashMap, hash_map::DefaultHasher},
hash::Hasher,
iter,
net::{IpAddr, SocketAddr},
sync::{Arc, Mutex},
time::{SystemTime, UNIX_EPOCH},
};
use arc_swap::ArcSwap;
use dashmap::DashMap;
use super::{
PeerId,
config::{ConfigLoader, Flags},
@@ -31,10 +35,11 @@ use crate::{
rpc_service::protected_port,
tunnel::matches_protocol,
};
use crossbeam::atomic::AtomicCell;
use hmac::{Hmac, Mac};
use sha2::Sha256;
use socket2::Protocol;
#[cfg(feature = "magic-dns")]
use crate::{
dns::config::{DnsConfigLoaderExt, DnsExportConfig, DnsGlobalCtxExt, zone::ZoneConfig},
utils::dns,
};
pub type NetworkIdentity = crate::common::config::NetworkIdentity;
@@ -48,11 +53,18 @@ pub enum GlobalCtxEvent {
PeerConnAdded(PeerConnInfo),
PeerConnRemoved(PeerConnInfo),
PeerInfoUpdated(Vec<PeerId>),
ListenerAdded(url::Url),
ListenerAddFailed(url::Url, String), // (url, error message)
ListenerAcceptFailed(url::Url, String), // (url, error message)
ConnectionAccepted(String, String), // (local url, remote url)
ConnectionError(String, String, String), // (local url, remote url, error message)
ListenerPortMappingEstablished {
local_listener: url::Url,
mapped_listener: url::Url,
backend: String,
},
Connecting(url::Url),
ConnectError(String, String, String), // (dst, ip version, error message)
@@ -63,6 +75,8 @@ pub enum GlobalCtxEvent {
DhcpIpv4Changed(Option<cidr::Ipv4Inet>, Option<cidr::Ipv4Inet>), // (old, new)
DhcpIpv4Conflicted(Option<cidr::Ipv4Inet>),
PublicIpv6Changed(Option<cidr::Ipv6Inet>, Option<cidr::Ipv6Inet>), // (old, new)
PublicIpv6RoutesUpdated(Vec<cidr::Ipv6Inet>, Vec<cidr::Ipv6Inet>), // (added, removed)
PortForwardAdded(PortForwardConfigPb),
@@ -70,6 +84,11 @@ pub enum GlobalCtxEvent {
ProxyCidrsUpdated(Vec<cidr::Ipv4Cidr>, Vec<cidr::Ipv4Cidr>), // (added, removed)
UdpBroadcastRelayStartResult {
capture_backend: Option<String>,
error: Option<String>,
},
CredentialChanged,
}
@@ -195,6 +214,8 @@ pub struct GlobalCtx {
cached_ipv4: AtomicCell<Option<cidr::Ipv4Inet>>,
cached_ipv6: AtomicCell<Option<cidr::Ipv6Inet>>,
public_ipv6_lease: AtomicCell<Option<cidr::Ipv6Inet>>,
public_ipv6_routes: Mutex<BTreeSet<std::net::Ipv6Addr>>,
cached_proxy_cidrs: AtomicCell<Option<Vec<ProxyNetworkConfig>>>,
ip_collector: Mutex<Option<Arc<IPCollector>>>,
@@ -204,9 +225,16 @@ pub struct GlobalCtx {
stun_info_collection: Mutex<Arc<dyn StunInfoCollectorTrait>>,
running_listeners: Mutex<Vec<url::Url>>,
advertised_ipv6_public_addr_prefix: Mutex<Option<cidr::Ipv6Cidr>>,
flags: ArcSwap<Flags>,
// Runtime/base advertised feature flags before config-owned fields are
// overlaid by set_flags. Keep this separate so config patches do not erase
// runtime state such as public-server role, IPv6 provider status, or the
// non-whitelist avoid-relay preference.
base_feature_flags: AtomicCell<PeerFeatureFlag>,
feature_flags: AtomicCell<PeerFeatureFlag>,
token_bucket_manager: TokenBucketManager,
@@ -234,11 +262,20 @@ impl std::fmt::Debug for GlobalCtx {
}
}
pub type ArcGlobalCtx = std::sync::Arc<GlobalCtx>;
pub type ArcGlobalCtx = Arc<GlobalCtx>;
impl GlobalCtx {
fn derive_feature_flags(flags: &Flags, current: Option<PeerFeatureFlag>) -> PeerFeatureFlag {
let mut feature_flags = current.unwrap_or_default();
fn apply_disable_relay_data_flag(
flags: &Flags,
mut feature_flags: PeerFeatureFlag,
) -> PeerFeatureFlag {
if flags.disable_relay_data {
feature_flags.avoid_relay_data = true;
}
feature_flags
}
fn derive_feature_flags(flags: &Flags, mut feature_flags: PeerFeatureFlag) -> PeerFeatureFlag {
feature_flags.kcp_input = !flags.disable_kcp_input;
feature_flags.no_relay_kcp = flags.disable_relay_kcp;
feature_flags.support_conn_list_sync = true;
@@ -246,7 +283,7 @@ impl GlobalCtx {
feature_flags.no_relay_quic = flags.disable_relay_quic;
feature_flags.need_p2p = flags.need_p2p;
feature_flags.disable_p2p = flags.disable_p2p;
feature_flags
Self::apply_disable_relay_data_flag(flags, feature_flags)
}
pub fn new(config_fs: impl ConfigLoader + 'static) -> Self {
@@ -257,6 +294,12 @@ impl GlobalCtx {
let (event_bus, _) = tokio::sync::broadcast::channel(16);
if let Some(dns_resolvers) = config_fs.get_dns_resolvers_config()
&& let Err(e) = crate::utils::dns::set_dns_resolvers(dns_resolvers)
{
crate::common::log::warn!("failed to set dns resolvers: {:?}", e);
}
let stun_info_collector = StunInfoCollector::new_with_default_servers();
if let Some(stun_servers) = config_fs.get_stun_servers() {
@@ -275,7 +318,8 @@ impl GlobalCtx {
let flags = config_fs.get_flags();
let feature_flags = Self::derive_feature_flags(&flags, None);
let base_feature_flags = PeerFeatureFlag::default();
let feature_flags = Self::derive_feature_flags(&flags, base_feature_flags);
let credential_storage_path = config_fs.get_credential_file();
let credential_manager = Arc::new(CredentialManager::new(credential_storage_path));
@@ -290,6 +334,8 @@ impl GlobalCtx {
event_bus,
cached_ipv4: AtomicCell::new(None),
cached_ipv6: AtomicCell::new(None),
public_ipv6_lease: AtomicCell::new(None),
public_ipv6_routes: Mutex::new(BTreeSet::new()),
cached_proxy_cidrs: AtomicCell::new(None),
ip_collector: Mutex::new(Some(Arc::new(IPCollector::new(
@@ -302,9 +348,12 @@ impl GlobalCtx {
stun_info_collection: Mutex::new(stun_info_collector),
running_listeners: Mutex::new(Vec::new()),
advertised_ipv6_public_addr_prefix: Mutex::new(None),
flags: ArcSwap::new(Arc::new(flags)),
base_feature_flags: AtomicCell::new(base_feature_flags),
feature_flags: AtomicCell::new(feature_flags),
token_bucket_manager: TokenBucketManager::new(),
@@ -376,6 +425,45 @@ impl GlobalCtx {
self.cached_ipv6.store(None);
}
pub fn get_public_ipv6_lease(&self) -> Option<cidr::Ipv6Inet> {
self.public_ipv6_lease.load()
}
pub fn set_public_ipv6_lease(&self, addr: Option<cidr::Ipv6Inet>) {
self.public_ipv6_lease.store(addr);
}
pub fn set_public_ipv6_routes(&self, routes: BTreeSet<cidr::Ipv6Inet>) {
*self.public_ipv6_routes.lock().unwrap() =
routes.into_iter().map(|route| route.address()).collect();
}
pub fn is_ip_local_ipv6(&self, ip: &std::net::Ipv6Addr) -> bool {
self.get_ipv6().map(|x| x.address() == *ip).unwrap_or(false)
|| self
.get_public_ipv6_lease()
.map(|x| x.address() == *ip)
.unwrap_or(false)
}
pub fn is_ip_easytier_managed_ipv6(&self, ip: &std::net::Ipv6Addr) -> bool {
self.is_ip_local_ipv6(ip) || self.public_ipv6_routes.lock().unwrap().contains(ip)
}
pub fn get_advertised_ipv6_public_addr_prefix(&self) -> Option<cidr::Ipv6Cidr> {
*self.advertised_ipv6_public_addr_prefix.lock().unwrap()
}
pub fn set_advertised_ipv6_public_addr_prefix(&self, prefix: Option<cidr::Ipv6Cidr>) -> bool {
let mut guard = self.advertised_ipv6_public_addr_prefix.lock().unwrap();
if *guard == prefix {
return false;
}
*guard = prefix;
true
}
pub fn get_id(&self) -> uuid::Uuid {
self.config.get_id()
}
@@ -390,7 +478,7 @@ impl GlobalCtx {
pub fn is_ip_local_virtual_ip(&self, ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => self.get_ipv4().map(|x| x.address() == *v4).unwrap_or(false),
IpAddr::V6(v6) => self.get_ipv6().map(|x| x.address() == *v6).unwrap_or(false),
IpAddr::V6(v6) => self.is_ip_local_ipv6(v6),
}
}
@@ -416,7 +504,7 @@ impl GlobalCtx {
}
pub fn get_hostname(&self) -> String {
return self.hostname.lock().unwrap().clone();
self.hostname.lock().unwrap().clone()
}
pub fn set_hostname(&self, hostname: String) {
@@ -461,7 +549,7 @@ impl GlobalCtx {
self.config.set_flags(flags.clone());
self.feature_flags.store(Self::derive_feature_flags(
&flags,
Some(self.feature_flags.load()),
self.base_feature_flags.load(),
));
self.flags.store(Arc::new(flags));
}
@@ -526,8 +614,53 @@ impl GlobalCtx {
self.feature_flags.load()
}
pub fn set_feature_flags(&self, flags: PeerFeatureFlag) {
self.feature_flags.store(flags);
/// Replace the runtime/base advertised flags as a complete snapshot.
///
/// This is intended for foreign scoped contexts that inherit an already
/// computed feature-flag snapshot from their parent. Most callers should use
/// a narrower setter so they do not accidentally overwrite unrelated runtime
/// state.
pub fn set_base_advertised_feature_flags(&self, feature_flags: PeerFeatureFlag) {
self.base_feature_flags.store(feature_flags);
let flags = self.flags.load();
self.feature_flags
.store(Self::apply_disable_relay_data_flag(
flags.as_ref(),
feature_flags,
));
}
/// Set the avoid-relay preference that is independent of disable_relay_data.
///
/// disable_relay_data still forces the effective advertised flag to true,
/// but this base preference is preserved when that config flag is toggled.
pub fn set_avoid_relay_data_preference(&self, avoid_relay_data: bool) -> bool {
let mut base_feature_flags = self.base_feature_flags.load();
base_feature_flags.avoid_relay_data = avoid_relay_data;
self.base_feature_flags.store(base_feature_flags);
let mut feature_flags = self.feature_flags.load();
let previous = feature_flags.avoid_relay_data;
feature_flags.avoid_relay_data = avoid_relay_data || self.flags.load().disable_relay_data;
self.feature_flags.store(feature_flags);
previous != feature_flags.avoid_relay_data
}
/// Set the runtime IPv6-provider advertised bit without touching
/// config-derived feature flags.
pub fn set_ipv6_public_addr_provider_feature_flag(&self, enabled: bool) -> bool {
let mut base_feature_flags = self.base_feature_flags.load();
base_feature_flags.ipv6_public_addr_provider = enabled;
self.base_feature_flags.store(base_feature_flags);
let mut feature_flags = self.feature_flags.load();
if feature_flags.ipv6_public_addr_provider == enabled {
return false;
}
feature_flags.ipv6_public_addr_provider = enabled;
self.feature_flags.store(feature_flags);
true
}
pub fn token_bucket_manager(&self) -> &TokenBucketManager {
@@ -640,23 +773,23 @@ impl GlobalCtx {
pub fn should_deny_proxy(&self, dst_addr: &SocketAddr, is_udp: bool) -> bool {
let _g = self.net_ns.guard();
let ip = dst_addr.ip();
// first check if ip is virtual ip
// first check if ip is an EasyTier-managed local address
// then try bind this ip, if succ means it is local ip
let dst_is_local_virtual_ip = self.is_ip_local_virtual_ip(&ip);
let dst_is_local_et_ip = self.is_ip_local_virtual_ip(&ip);
// this is an expensive operation, should be called sparingly
// 1. tcp/kcp/quic call this only after proxy conn is established
// 2. udp cache the result in nat entry
let dst_is_local_phy_ip = std::net::UdpSocket::bind(format!("{}:0", ip)).is_ok();
tracing::trace!(
"check should_deny_proxy: dst_addr={}, dst_is_local_virtual_ip={}, dst_is_local_phy_ip={}, is_udp={}",
"check should_deny_proxy: dst_addr={}, dst_is_local_et_ip={}, dst_is_local_phy_ip={}, is_udp={}",
dst_addr,
dst_is_local_virtual_ip,
dst_is_local_et_ip,
dst_is_local_phy_ip,
is_udp
);
if dst_is_local_virtual_ip || dst_is_local_phy_ip {
if dst_is_local_et_ip || dst_is_local_phy_ip {
// if is local ip, make sure the port is not one of the listening ports
self.is_port_in_running_listeners(dst_addr.port(), is_udp)
|| (!is_udp && protected_port::is_protected_tcp_port(dst_addr.port()))
@@ -666,6 +799,39 @@ impl GlobalCtx {
}
}
#[cfg(feature = "magic-dns")]
impl DnsGlobalCtxExt for GlobalCtx {
fn dns_self_zone(&self) -> ZoneConfig {
use hickory_proto::rr::Name;
let dns = self.config.get_dns();
let name: Name = dns
.name
.clone()
.unwrap_or_else(|| dns::parse(self.get_hostname()))
.into();
let fqdn = name.append_domain(&dns.domain).unwrap_or_default().into();
let ipv4 = self.get_ipv4().map(|ip| ip.address());
let ipv6 = self.get_ipv6().map(|ip| ip.address());
let ipv6 = ipv6.map(|a| vec![a]).unwrap_or_default();
ZoneConfig::dedicated(fqdn, ipv4, ipv6)
}
fn dns_export_config(&self) -> DnsExportConfig {
DnsExportConfig {
zones: self
.dns_iter_zones()
.filter(|z| z.policy.export.as_ref().is_some_and(|f| !f.disabled)) // TODO: check policies of parent zones
.map(ZoneConfig::into_data)
.collect(),
}
}
fn dns_iter_zones(&self) -> impl Iterator<Item = ZoneConfig> {
iter::once(self.dns_self_zone()).chain(self.config.get_dns().into_parsed().zones)
}
}
#[cfg(test)]
pub mod tests {
use crate::{
@@ -744,7 +910,7 @@ pub mod tests {
let mut feature_flags = global_ctx.get_feature_flags();
feature_flags.avoid_relay_data = true;
feature_flags.is_public_server = true;
global_ctx.set_feature_flags(feature_flags);
global_ctx.set_base_advertised_feature_flags(feature_flags);
let mut flags = global_ctx.get_flags().clone();
flags.disable_kcp_input = true;
@@ -765,6 +931,84 @@ pub mod tests {
assert!(feature_flags.support_conn_list_sync);
assert!(feature_flags.avoid_relay_data);
assert!(feature_flags.is_public_server);
assert!(!feature_flags.ipv6_public_addr_provider);
}
#[tokio::test]
async fn set_base_advertised_feature_flags_applies_current_values() {
let config = TomlConfigLoader::default();
let global_ctx = GlobalCtx::new(config);
let feature_flags = PeerFeatureFlag {
kcp_input: false,
no_relay_kcp: true,
quic_input: false,
no_relay_quic: true,
is_public_server: true,
..Default::default()
};
global_ctx.set_base_advertised_feature_flags(feature_flags);
assert_eq!(global_ctx.get_feature_flags(), feature_flags);
}
#[tokio::test]
async fn set_base_advertised_feature_flags_keeps_disable_relay_data_effective() {
let config = TomlConfigLoader::default();
let global_ctx = GlobalCtx::new(config);
let mut flags = global_ctx.get_flags().clone();
flags.disable_relay_data = true;
global_ctx.set_flags(flags);
let mut feature_flags = global_ctx.get_feature_flags();
feature_flags.avoid_relay_data = false;
feature_flags.is_public_server = true;
global_ctx.set_base_advertised_feature_flags(feature_flags);
let advertised_feature_flags = global_ctx.get_feature_flags();
assert!(advertised_feature_flags.avoid_relay_data);
assert!(advertised_feature_flags.is_public_server);
let mut flags = global_ctx.get_flags().clone();
flags.disable_relay_data = false;
global_ctx.set_flags(flags);
let advertised_feature_flags = global_ctx.get_feature_flags();
assert!(!advertised_feature_flags.avoid_relay_data);
assert!(advertised_feature_flags.is_public_server);
}
#[tokio::test]
async fn disable_relay_data_sets_avoid_relay_feature_flag() {
let config = TomlConfigLoader::default();
let global_ctx = GlobalCtx::new(config);
let mut flags = global_ctx.get_flags().clone();
flags.disable_relay_data = true;
global_ctx.set_flags(flags);
assert!(global_ctx.get_feature_flags().avoid_relay_data);
let mut flags = global_ctx.get_flags().clone();
flags.disable_relay_data = false;
global_ctx.set_flags(flags);
assert!(!global_ctx.get_feature_flags().avoid_relay_data);
global_ctx.set_avoid_relay_data_preference(true);
let mut flags = global_ctx.get_flags().clone();
flags.disable_relay_data = true;
global_ctx.set_flags(flags);
assert!(global_ctx.get_feature_flags().avoid_relay_data);
let mut flags = global_ctx.get_flags().clone();
flags.disable_relay_data = false;
global_ctx.set_flags(flags);
assert!(global_ctx.get_feature_flags().avoid_relay_data);
}
#[tokio::test]
@@ -784,6 +1028,40 @@ pub mod tests {
protected_port::clear_protected_tcp_ports_for_test();
}
#[tokio::test]
async fn virtual_ipv6_and_public_ipv6_lease_are_stored_separately() {
let config = TomlConfigLoader::default();
let global_ctx = GlobalCtx::new(config);
let virtual_ipv6 = "fd00::1/64".parse().unwrap();
let public_ipv6 = "2001:db8::2/64".parse().unwrap();
global_ctx.set_ipv6(Some(virtual_ipv6));
global_ctx.set_public_ipv6_lease(Some(public_ipv6));
assert_eq!(global_ctx.get_ipv6(), Some(virtual_ipv6));
assert_eq!(global_ctx.get_public_ipv6_lease(), Some(public_ipv6));
}
#[tokio::test]
async fn public_ipv6_lease_is_treated_as_local_ip() {
protected_port::clear_protected_tcp_ports_for_test();
let config = TomlConfigLoader::default();
let global_ctx = GlobalCtx::new(config);
let public_ipv6 = "2001:db8::2/64".parse().unwrap();
let listener: url::Url = "tcp://[2001:db8::2]:11010".parse().unwrap();
global_ctx.set_public_ipv6_lease(Some(public_ipv6));
global_ctx.add_running_listener(listener);
let ip = std::net::IpAddr::V6(public_ipv6.address());
let socket = SocketAddr::from((public_ipv6.address(), 11010));
assert!(global_ctx.is_ip_local_virtual_ip(&ip));
assert!(global_ctx.should_deny_proxy(&socket, false));
protected_port::clear_protected_tcp_ports_for_test();
}
pub fn get_mock_global_ctx_with_network(
network_identy: Option<NetworkIdentity>,
) -> ArcGlobalCtx {
+11
View File
@@ -166,3 +166,14 @@ pub type IfConfiger = DummyIfConfiger;
#[cfg(target_os = "windows")]
pub use windows::RegistryManager;
#[cfg(target_os = "linux")]
pub(crate) fn list_ipv6_route_messages()
-> Result<Vec<netlink_packet_route::route::RouteMessage>, Error> {
netlink::NetlinkIfConfiger::list_ipv6_route_messages()
}
#[cfg(target_os = "linux")]
pub(crate) fn get_interface_index(name: &str) -> Result<u32, Error> {
netlink::NetlinkIfConfiger::get_interface_index(name)
}
+200 -16
View File
@@ -160,7 +160,7 @@ impl From<RouteMessage> for Route {
pub struct NetlinkIfConfiger {}
impl NetlinkIfConfiger {
fn get_interface_index(name: &str) -> Result<u32, Error> {
pub(crate) fn get_interface_index(name: &str) -> Result<u32, Error> {
let name = CString::new(name).with_context(|| "failed to convert interface name")?;
match unsafe { libc::if_nametoindex(name.as_ptr()) } {
0 => Err(std::io::Error::last_os_error().into()),
@@ -311,7 +311,7 @@ impl NetlinkIfConfiger {
Self::set_flags_op(name, SIOCGIFFLAGS, InterfaceFlags::empty())
}
fn list_routes() -> Result<Vec<RouteMessage>, Error> {
fn list_route_messages(address_family: AddressFamily) -> Result<Vec<RouteMessage>, Error> {
let mut message = RouteMessage::default();
message.header.table = RouteHeader::RT_TABLE_UNSPEC;
@@ -320,7 +320,7 @@ impl NetlinkIfConfiger {
message.header.scope = RouteScope::Universe;
message.header.kind = RouteType::Unicast;
message.header.address_family = AddressFamily::Inet;
message.header.address_family = address_family;
message.header.destination_prefix_length = 0;
message.header.source_prefix_length = 0;
@@ -367,6 +367,14 @@ impl NetlinkIfConfiger {
Ok(ret_vec)
}
fn list_routes() -> Result<Vec<RouteMessage>, Error> {
Self::list_route_messages(AddressFamily::Inet)
}
pub(crate) fn list_ipv6_route_messages() -> Result<Vec<RouteMessage>, Error> {
Self::list_route_messages(AddressFamily::Inet6)
}
}
#[async_trait]
@@ -551,12 +559,9 @@ impl IfConfiguerTrait for NetlinkIfConfiger {
message.header.scope = RouteScope::Universe;
message.header.kind = RouteType::Unicast;
// Add metric (cost) if specified
if let Some(cost) = cost {
message
.attributes
.push(RouteAttribute::Priority(cost as u32));
}
message
.attributes
.push(RouteAttribute::Priority(cost.unwrap_or(65535) as u32));
message
.attributes
@@ -564,9 +569,11 @@ impl IfConfiguerTrait for NetlinkIfConfiger {
name,
)?));
message
.attributes
.push(RouteAttribute::Destination(RouteAddress::Inet6(address)));
if cidr_prefix != 0 {
message
.attributes
.push(RouteAttribute::Destination(RouteAddress::Inet6(address)));
}
send_netlink_req_and_wait_one_resp(RouteNetlinkMessage::NewRoute(message), false)
}
@@ -577,7 +584,7 @@ impl IfConfiguerTrait for NetlinkIfConfiger {
address: std::net::Ipv6Addr,
cidr_prefix: u8,
) -> Result<(), Error> {
let routes = Self::list_routes()?;
let routes = Self::list_route_messages(AddressFamily::Inet6)?;
let ifidx = NetlinkIfConfiger::get_interface_index(name)?;
for msg in routes {
@@ -598,29 +605,82 @@ impl IfConfiguerTrait for NetlinkIfConfiger {
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
const DUMMY_IFACE_NAME: &str = "dummy";
fn run_cmd(cmd: &str) -> String {
let output = std::process::Command::new("sh")
let output = Command::new("sh")
.arg("-c")
.arg(cmd)
.output()
.expect("failed to execute process");
assert!(
output.status.success(),
"command failed: {cmd}\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
String::from_utf8(output.stdout).unwrap()
}
fn run_ip(args: &[&str]) {
let output = Command::new("ip")
.args(args)
.output()
.expect("failed to execute ip process");
assert!(
output.status.success(),
"ip command failed: {:?}\nstdout: {}\nstderr: {}",
args,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
}
fn test_iface_name(tag: &str) -> String {
format!("et{}{:x}", tag, std::process::id() & 0xffff)
}
struct ScopedDummyLink {
name: String,
}
impl ScopedDummyLink {
fn new(name: &str) -> Self {
let _ = Command::new("ip").args(["link", "del", name]).output();
run_ip(&["link", "add", name, "type", "dummy"]);
run_ip(&["link", "set", name, "up"]);
Self {
name: name.to_string(),
}
}
}
impl Drop for ScopedDummyLink {
fn drop(&mut self) {
let _ = Command::new("ip")
.args(["link", "del", &self.name])
.output();
}
}
struct PrepareEnv {}
impl PrepareEnv {
fn new() -> Self {
let _ = run_cmd(&format!("sudo ip link add {} type dummy", DUMMY_IFACE_NAME));
let _ = Command::new("ip")
.args(["link", "del", DUMMY_IFACE_NAME])
.output();
let _ = run_cmd(&format!("ip link add {} type dummy", DUMMY_IFACE_NAME));
PrepareEnv {}
}
}
impl Drop for PrepareEnv {
fn drop(&mut self) {
let _ = run_cmd(&format!("sudo ip link del {}", DUMMY_IFACE_NAME));
let _ = Command::new("ip")
.args(["link", "del", DUMMY_IFACE_NAME])
.output();
}
}
@@ -701,4 +761,128 @@ mod tests {
.collect::<Vec<_>>();
assert!(!routes.contains(&IpAddr::V4("10.5.5.0".parse().unwrap())));
}
#[serial_test::serial]
#[tokio::test]
async fn ipv6_addr_readback_test() {
let iface = test_iface_name("a");
let _link = ScopedDummyLink::new(&iface);
run_ip(&["-6", "addr", "add", "2001:db8:1234::2/64", "dev", &iface]);
let addrs = NetlinkIfConfiger::list_addresses(&iface).unwrap();
assert!(addrs.iter().any(|addr| {
addr.address() == IpAddr::V6("2001:db8:1234::2".parse().unwrap())
&& addr.network_length() == 64
}));
}
#[serial_test::serial]
#[tokio::test]
async fn ipv6_route_readback_test() {
let wan_if = test_iface_name("rw");
let lan_if = test_iface_name("rl");
let _wan = ScopedDummyLink::new(&wan_if);
let _lan = ScopedDummyLink::new(&lan_if);
run_ip(&[
"-6",
"addr",
"add",
"2001:db8:100:ffff::2/64",
"dev",
&wan_if,
]);
run_ip(&[
"-6",
"route",
"add",
"default",
"from",
"2001:db8:100::/56",
"dev",
&wan_if,
]);
run_ip(&["-6", "route", "add", "2001:db8:100::/56", "dev", &lan_if]);
let wan_ifindex = NetlinkIfConfiger::get_interface_index(&wan_if).unwrap();
let lan_ifindex = NetlinkIfConfiger::get_interface_index(&lan_if).unwrap();
let routes = NetlinkIfConfiger::list_ipv6_route_messages().unwrap();
assert!(routes.iter().any(|route| {
route.header.kind == RouteType::Unicast
&& route.header.source_prefix_length == 56
&& route.attributes.iter().any(|attr| {
matches!(
attr,
RouteAttribute::Source(RouteAddress::Inet6(addr))
if *addr == "2001:db8:100::".parse::<std::net::Ipv6Addr>().unwrap()
)
})
&& route
.attributes
.iter()
.any(|attr| matches!(attr, RouteAttribute::Oif(index) if *index == wan_ifindex))
&& !route
.attributes
.iter()
.any(|attr| matches!(attr, RouteAttribute::Destination(_)))
}));
assert!(routes.iter().any(|route| {
route.header.kind == RouteType::Unicast
&& route.header.destination_prefix_length == 56
&& route.attributes.iter().any(|attr| {
matches!(
attr,
RouteAttribute::Destination(RouteAddress::Inet6(addr))
if *addr == "2001:db8:100::".parse::<std::net::Ipv6Addr>().unwrap()
)
})
&& route
.attributes
.iter()
.any(|attr| matches!(attr, RouteAttribute::Oif(index) if *index == lan_ifindex))
}));
}
#[serial_test::serial]
#[tokio::test]
async fn ipv6_route_remove_test() {
let iface = test_iface_name("rr");
let _link = ScopedDummyLink::new(&iface);
let ifcfg = NetlinkIfConfiger {};
let route_addr = "2001:db8:200::".parse::<std::net::Ipv6Addr>().unwrap();
ifcfg
.add_ipv6_route(&iface, route_addr, 56, None)
.await
.unwrap();
let ifindex = NetlinkIfConfiger::get_interface_index(&iface).unwrap();
let has_route = |routes: &[RouteMessage]| {
routes.iter().any(|route| {
route.header.destination_prefix_length == 56
&& route.attributes.iter().any(|attr| {
matches!(
attr,
RouteAttribute::Destination(RouteAddress::Inet6(addr)) if *addr == route_addr
)
})
&& route
.attributes
.iter()
.any(|attr| matches!(attr, RouteAttribute::Oif(index) if *index == ifindex))
})
};
let routes = NetlinkIfConfiger::list_ipv6_route_messages().unwrap();
assert!(has_route(&routes));
ifcfg
.remove_ipv6_route(&iface, route_addr, 56)
.await
.unwrap();
let routes = NetlinkIfConfiger::list_ipv6_route_messages().unwrap();
assert!(!has_route(&routes));
}
}
+9 -8
View File
@@ -6,15 +6,16 @@ use cidr::{Ipv4Inet, Ipv6Inet};
use std::{
io,
net::{Ipv4Addr, Ipv6Addr},
ptr::null_mut,
};
use windows_sys::Win32::{
use windows::Win32::NetworkManagement::IpHelper::INTERNAL_IF_OPER_STATUS;
use windows::Win32::{
Foundation::NO_ERROR,
NetworkManagement::IpHelper::{GetIfEntry, MIB_IFROW, SetIfEntry},
System::Diagnostics::Debug::{
FORMAT_MESSAGE_FROM_SYSTEM, FORMAT_MESSAGE_IGNORE_INSERTS, FormatMessageW,
},
};
use windows::core::PWSTR;
use winreg::{
RegKey,
enums::{HKEY_LOCAL_MACHINE, KEY_READ, KEY_WRITE},
@@ -32,12 +33,12 @@ fn format_win_error(error: u32) -> String {
unsafe {
FormatMessageW(
flags,
null_mut(),
None,
error,
0,
buffer.as_mut_ptr(),
PWSTR(buffer.as_mut_ptr()),
size,
null_mut(),
None,
);
}
let str_end = buffer.iter().position(|&b| b == 0).unwrap_or(buffer.len());
@@ -100,7 +101,7 @@ impl WindowsIfConfiger {
dwPhysAddrLen: 0,
bPhysAddr: [0; 8],
dwAdminStatus: if up { 1 } else { 2 }, // 1 = up, 2 = down
dwOperStatus: 0,
dwOperStatus: INTERNAL_IF_OPER_STATUS(0),
dwLastChange: 0,
dwInOctets: 0,
dwInUcastPkts: 0,
@@ -118,8 +119,8 @@ impl WindowsIfConfiger {
bDescr: [0; 256],
};
if GetIfEntry(&mut if_row) == NO_ERROR {
if SetIfEntry(&if_row) == NO_ERROR {
if GetIfEntry(&mut if_row) == NO_ERROR.0 {
if SetIfEntry(&if_row) == NO_ERROR.0 {
Ok(())
} else {
Err(anyhow::anyhow!("Failed to set interface status").into())
+193 -11
View File
@@ -58,6 +58,21 @@ fn parse_env_filter(default_level: Option<LevelFilter>) -> Result<EnvFilter, any
.with_context(|| "failed to create env filter")
}
fn parse_static_filter(level: LevelFilter) -> Result<EnvFilter, anyhow::Error> {
EnvFilter::builder()
.with_default_directive(level.into())
.parse("")
.with_context(|| "failed to create static filter")
}
fn parse_file_filter(level: LevelFilter) -> Result<EnvFilter, anyhow::Error> {
if matches!(level, LevelFilter::OFF) {
parse_static_filter(level)
} else {
parse_env_filter(Some(level))
}
}
fn is_log(meta: &Metadata) -> bool {
meta.target() == LOG_TARGET || meta.target().starts_with(&format!("{LOG_TARGET}::"))
}
@@ -165,14 +180,17 @@ fn file_layers(
) -> anyhow::Result<(Vec<BoxLayer>, Option<NewFilterSender>)> {
let mut layers = Vec::new();
let level = config.level.map(|s| s.parse().unwrap());
let level = config
.level
.map(|s| s.parse().unwrap())
.unwrap_or(LevelFilter::OFF);
if matches!(level, Some(LevelFilter::OFF)) && !reload {
if matches!(level, LevelFilter::OFF) && !reload {
return Ok((layers, None));
}
let (file_filter, file_filter_reloader) =
tracing_subscriber::reload::Layer::<_, Registry>::new(parse_env_filter(level)?);
tracing_subscriber::reload::Layer::<_, Registry>::new(parse_file_filter(level)?);
let layer = |wrapper| {
layer()
@@ -218,9 +236,7 @@ fn file_layers(
// 初始化全局状态
let _ = LOGGER_LEVEL_SENDER.set(std::sync::Mutex::new(tx.clone()));
if let Some(level) = level {
let _ = CURRENT_LOG_LEVEL.set(std::sync::Mutex::new(level.to_string()));
}
let _ = CURRENT_LOG_LEVEL.set(std::sync::Mutex::new(level.to_string()));
std::thread::spawn(move || {
while let Ok(lf) = rx.recv() {
@@ -232,11 +248,7 @@ fn file_layers(
}
};
let mut new_filter = match EnvFilter::builder()
.with_default_directive(parsed_level.into())
.from_env()
.with_context(|| "failed to create file filter")
{
let mut new_filter = match parse_file_filter(parsed_level) {
Ok(filter) => Some(filter),
Err(e) => {
error!("Failed to build new log filter for {:?}: {:?}", lf, e);
@@ -268,6 +280,36 @@ mod tests {
use super::*;
use crate::common::config::FileLoggerConfig;
const RUST_LOG: &str = "RUST_LOG";
struct EnvVarGuard {
key: &'static str,
previous: Option<std::ffi::OsString>,
}
impl EnvVarGuard {
fn set(key: &'static str, value: &str) -> Self {
let previous = std::env::var_os(key);
unsafe { std::env::set_var(key, value) };
Self { key, previous }
}
fn unset(key: &'static str) -> Self {
let previous = std::env::var_os(key);
unsafe { std::env::remove_var(key) };
Self { key, previous }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match &self.previous {
Some(value) => unsafe { std::env::set_var(self.key, value) },
None => unsafe { std::env::remove_var(self.key) },
}
}
}
#[ctor::ctor]
fn init() {
let _ = Registry::default()
@@ -276,7 +318,147 @@ mod tests {
}
#[test]
fn default_file_logger_level_is_off_without_reload() {
let (layers, sender) = file_layers(FileLoggerConfig::default(), false).unwrap();
assert!(layers.is_empty());
assert!(sender.is_none());
}
#[test]
#[serial_test::serial]
fn default_file_logger_level_filters_info_with_reload() {
let _guard = EnvVarGuard::set(RUST_LOG, "info");
let temp_dir = tempfile::tempdir().unwrap();
let log_file_name = "default-off-test.log".to_string();
let log_path = temp_dir.path().join(&log_file_name);
let cfg = FileLoggerConfig {
file: Some(log_file_name),
dir: Some(temp_dir.path().to_string_lossy().to_string()),
..Default::default()
};
let (layers, _sender) = file_layers(cfg, true).unwrap();
let marker = "default-file-logger-off-marker";
let subscriber = Registry::default().with(layers);
tracing::subscriber::with_default(subscriber, || {
tracing::info!(target: LOG_TARGET, "{}", marker);
std::thread::sleep(std::time::Duration::from_millis(300));
});
let content = std::fs::read_to_string(&log_path).unwrap_or_default();
assert!(
!content.contains(marker),
"default file logger level should filter info logs"
);
}
#[test]
#[serial_test::serial]
fn file_logger_level_uses_env_filter_when_enabled() {
let _guard = EnvVarGuard::set(RUST_LOG, "debug");
let temp_dir = tempfile::tempdir().unwrap();
let log_file_name = "env-filter-test.log".to_string();
let log_path = temp_dir.path().join(&log_file_name);
let cfg = FileLoggerConfig {
level: Some(LevelFilter::INFO.to_string()),
file: Some(log_file_name),
dir: Some(temp_dir.path().to_string_lossy().to_string()),
..Default::default()
};
let (layers, _sender) = file_layers(cfg, true).unwrap();
let marker = "file-logger-env-filter-marker";
let subscriber = Registry::default().with(layers);
tracing::subscriber::with_default(subscriber, || {
tracing::debug!(target: LOG_TARGET, "{}", marker);
std::thread::sleep(std::time::Duration::from_millis(300));
});
let content = std::fs::read_to_string(&log_path).unwrap_or_default();
assert!(
content.contains(marker),
"enabled file logger should use RUST_LOG directives"
);
}
#[test]
#[serial_test::serial]
fn file_logger_reload_uses_env_filter_when_enabled() {
let _guard = EnvVarGuard::set(RUST_LOG, "debug");
let temp_dir = tempfile::tempdir().unwrap();
let log_file_name = "reload-env-filter-test.log".to_string();
let log_path = temp_dir.path().join(&log_file_name);
let cfg = FileLoggerConfig {
file: Some(log_file_name),
dir: Some(temp_dir.path().to_string_lossy().to_string()),
..Default::default()
};
let (layers, sender) = file_layers(cfg, true).unwrap();
let sender = sender.expect("reload=true should return a sender");
let marker = "file-logger-reload-env-filter-marker";
let subscriber = Registry::default().with(layers);
tracing::subscriber::with_default(subscriber, || {
sender.send(LevelFilter::INFO.to_string()).unwrap();
std::thread::sleep(std::time::Duration::from_millis(300));
tracing::debug!(target: LOG_TARGET, "{}", marker);
std::thread::sleep(std::time::Duration::from_millis(300));
});
let content = std::fs::read_to_string(&log_path).unwrap_or_default();
assert!(
content.contains(marker),
"file logger enabled by reload should use RUST_LOG directives"
);
}
#[test]
#[serial_test::serial]
fn file_logger_reload_off_ignores_env_filter() {
let _guard = EnvVarGuard::set(RUST_LOG, "info");
let temp_dir = tempfile::tempdir().unwrap();
let log_file_name = "reload-off-test.log".to_string();
let log_path = temp_dir.path().join(&log_file_name);
let cfg = FileLoggerConfig {
level: Some(LevelFilter::INFO.to_string()),
file: Some(log_file_name),
dir: Some(temp_dir.path().to_string_lossy().to_string()),
..Default::default()
};
let (layers, sender) = file_layers(cfg, true).unwrap();
let sender = sender.expect("reload=true should return a sender");
let marker = "file-logger-reload-off-marker";
let subscriber = Registry::default().with(layers);
tracing::subscriber::with_default(subscriber, || {
sender.send(LevelFilter::OFF.to_string()).unwrap();
std::thread::sleep(std::time::Duration::from_millis(300));
tracing::info!(target: LOG_TARGET, "{}", marker);
std::thread::sleep(std::time::Duration::from_millis(300));
});
let content = std::fs::read_to_string(&log_path).unwrap_or_default();
assert!(
!content.contains(marker),
"disabled file logger should ignore RUST_LOG directives"
);
}
#[test]
#[serial_test::serial]
fn test_logger_reload() {
let _guard = EnvVarGuard::unset(RUST_LOG);
let temp_dir = tempfile::tempdir().unwrap();
let log_file_name = "reload-test.log".to_string();
let log_path = temp_dir.path().join(&log_file_name);
+594
View File
@@ -0,0 +1,594 @@
use std::{
env,
ffi::OsString,
io::Write as _,
path::{Path, PathBuf},
time::{Duration, Instant},
};
use crate::utils::hostname;
use anyhow::Context as _;
#[cfg(unix)]
use nix::{
errno::Errno,
fcntl::{Flock, FlockArg},
};
#[derive(Debug, Clone, Default)]
pub struct MachineIdOptions {
pub explicit_machine_id: Option<String>,
pub state_dir: Option<PathBuf>,
}
pub fn resolve_machine_id(opts: &MachineIdOptions) -> anyhow::Result<uuid::Uuid> {
if let Some(explicit_machine_id) = opts.explicit_machine_id.as_deref() {
return Ok(parse_or_hash_machine_id(explicit_machine_id));
}
let state_file = resolve_machine_id_state_file(opts.state_dir.as_deref())?;
let allow_legacy_machine_uid_migration =
should_attempt_legacy_machine_uid_migration(&state_file);
if let Some(machine_id) = read_state_machine_id(&state_file)? {
return Ok(machine_id);
}
if let Some(machine_id) = read_legacy_machine_id_file() {
return persist_machine_id(&state_file, machine_id);
}
if allow_legacy_machine_uid_migration
&& let Some(machine_id) = resolve_legacy_machine_uid_hash()
{
return persist_machine_id(&state_file, machine_id);
}
let machine_id = resolve_new_machine_id().unwrap_or_else(uuid::Uuid::new_v4);
persist_machine_id(&state_file, machine_id)
}
fn parse_or_hash_machine_id(raw: &str) -> uuid::Uuid {
if let Ok(mid) = uuid::Uuid::parse_str(raw.trim()) {
return mid;
}
digest_uuid_from_str(raw)
}
fn digest_uuid_from_str(raw: &str) -> uuid::Uuid {
let mut b = [0u8; 16];
crate::tunnel::generate_digest_from_str("", raw, &mut b);
uuid::Uuid::from_bytes(b)
}
fn resolve_machine_id_state_file(state_dir: Option<&Path>) -> anyhow::Result<PathBuf> {
let state_dir = match state_dir {
Some(dir) => dir.to_path_buf(),
None => default_machine_id_state_dir()?,
};
Ok(state_dir.join("machine_id"))
}
fn non_empty_os_string(value: Option<OsString>) -> Option<OsString> {
value.filter(|value| !value.is_empty())
}
#[cfg(target_os = "linux")]
fn default_linux_machine_id_state_dir(
xdg_data_home: Option<OsString>,
home: Option<OsString>,
) -> PathBuf {
if let Some(path) = non_empty_os_string(xdg_data_home) {
return PathBuf::from(path).join("easytier");
}
if let Some(home) = non_empty_os_string(home) {
return PathBuf::from(home)
.join(".local")
.join("share")
.join("easytier");
}
PathBuf::from("/var/lib/easytier")
}
fn default_machine_id_state_dir() -> anyhow::Result<PathBuf> {
cfg_select! {
target_os = "linux" => Ok(default_linux_machine_id_state_dir(
env::var_os("XDG_DATA_HOME"),
env::var_os("HOME"),
)),
all(target_os = "macos", not(feature = "macos-ne")) => {
let home = non_empty_os_string(env::var_os("HOME"))
.ok_or_else(|| anyhow::anyhow!("HOME is not set, cannot resolve machine id state directory"))?;
Ok(PathBuf::from(home)
.join("Library")
.join("Application Support")
.join("com.easytier"))
},
target_os = "windows" => {
let local_app_data = non_empty_os_string(env::var_os("LOCALAPPDATA")).ok_or_else(|| {
anyhow::anyhow!("LOCALAPPDATA is not set, cannot resolve machine id state directory")
})?;
Ok(PathBuf::from(local_app_data).join("easytier"))
},
target_os = "freebsd" => {
let home = non_empty_os_string(env::var_os("HOME"))
.ok_or_else(|| anyhow::anyhow!("HOME is not set, cannot resolve machine id state directory"))?;
Ok(PathBuf::from(home).join(".local").join("share").join("easytier"))
},
target_os = "android" => {
anyhow::bail!("machine id state directory must be provided explicitly on Android");
},
_ => anyhow::bail!("machine id state directory is unsupported on this platform"),
}
}
fn read_state_machine_id(path: &Path) -> anyhow::Result<Option<uuid::Uuid>> {
let Some(contents) = read_optional_file(path)? else {
return Ok(None);
};
let machine_id = uuid::Uuid::parse_str(contents.trim())
.with_context(|| format!("invalid machine id in state file {}", path.display()))?;
Ok(Some(machine_id))
}
fn read_legacy_machine_id_file() -> Option<uuid::Uuid> {
let path = legacy_machine_id_file_path()?;
read_legacy_machine_id_file_at(&path)
}
fn read_legacy_machine_id_file_at(path: &Path) -> Option<uuid::Uuid> {
let contents = match std::fs::read_to_string(path) {
Ok(contents) => contents,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return None,
Err(err) => {
tracing::warn!(
path = %path.display(),
%err,
"ignoring unreadable legacy machine id file"
);
return None;
}
};
match uuid::Uuid::parse_str(contents.trim()) {
Ok(machine_id) => Some(machine_id),
Err(err) => {
tracing::warn!(
path = %path.display(),
%err,
"ignoring invalid legacy machine id file"
);
None
}
}
}
fn legacy_machine_id_file_path() -> Option<PathBuf> {
std::env::current_exe()
.ok()
.map(|path| path.with_file_name("et_machine_id"))
}
fn read_optional_file(path: &Path) -> anyhow::Result<Option<String>> {
match std::fs::read_to_string(path) {
Ok(contents) => Ok(Some(contents)),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(err).with_context(|| format!("failed to read {}", path.display())),
}
}
fn should_attempt_legacy_machine_uid_migration(state_file: &Path) -> bool {
let Some(state_dir) = state_file.parent() else {
return false;
};
let Ok(mut entries) = std::fs::read_dir(state_dir) else {
return false;
};
entries.any(|entry| entry.is_ok())
}
fn resolve_legacy_machine_uid_hash() -> Option<uuid::Uuid> {
machine_uid_seed().map(|seed| digest_uuid_from_str(seed.as_str()))
}
fn resolve_new_machine_id() -> Option<uuid::Uuid> {
let seed = machine_uid_seed()?;
#[cfg(target_os = "linux")]
{
let seed = linux_machine_id_seed(&seed);
Some(digest_uuid_from_str(&seed))
}
#[cfg(not(target_os = "linux"))]
{
Some(digest_uuid_from_str(&seed))
}
}
#[cfg(any(
target_os = "linux",
all(target_os = "macos", not(feature = "macos-ne")),
target_os = "windows",
target_os = "freebsd"
))]
fn machine_uid_seed() -> Option<String> {
machine_uid::get()
.ok()
.filter(|value| !value.trim().is_empty())
}
#[cfg(not(any(
target_os = "linux",
all(target_os = "macos", not(feature = "macos-ne")),
target_os = "windows",
target_os = "freebsd"
)))]
fn machine_uid_seed() -> Option<String> {
None
}
#[cfg(target_os = "linux")]
fn linux_machine_id_seed(machine_uid: &str) -> String {
let mut seed = format!("machine_uid={machine_uid}");
let hostname = hostname();
if !hostname.is_empty() {
seed.push_str("\nhostname=");
seed.push_str(&hostname);
}
let mac_addresses = collect_linux_mac_addresses();
if !mac_addresses.is_empty() {
seed.push_str("\nmacs=");
seed.push_str(&mac_addresses.join(","));
}
seed
}
#[cfg(target_os = "linux")]
fn collect_linux_mac_addresses() -> Vec<String> {
let mut macs = Vec::new();
let Ok(entries) = std::fs::read_dir("/sys/class/net") else {
return macs;
};
for entry in entries.flatten() {
let Ok(name) = entry.file_name().into_string() else {
continue;
};
if name == "lo" {
continue;
}
let address_path = entry.path().join("address");
let Ok(address) = std::fs::read_to_string(address_path) else {
continue;
};
let address = address.trim().to_ascii_lowercase();
if address.is_empty() || address == "00:00:00:00:00:00" {
continue;
}
macs.push(address);
}
macs.sort();
macs.dedup();
macs.truncate(3);
macs
}
fn persist_machine_id(path: &Path, machine_id: uuid::Uuid) -> anyhow::Result<uuid::Uuid> {
if let Some(existing) = read_state_machine_id(path)? {
return Ok(existing);
}
let _lock = MachineIdWriteLock::acquire(path)?;
if let Some(existing) = read_state_machine_id(path)? {
return Ok(existing);
}
write_uuid_file_atomically(path, machine_id)?;
Ok(machine_id)
}
fn write_uuid_file_atomically(path: &Path, machine_id: uuid::Uuid) -> anyhow::Result<()> {
let parent = path.parent().ok_or_else(|| {
anyhow::anyhow!(
"machine id state file {} has no parent directory",
path.display()
)
})?;
std::fs::create_dir_all(parent).with_context(|| {
format!(
"failed to create machine id state directory {}",
parent.display()
)
})?;
let tmp_path = parent.join(format!(
".machine_id.tmp-{}-{}",
std::process::id(),
uuid::Uuid::new_v4()
));
{
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp_path)
.with_context(|| format!("failed to create {}", tmp_path.display()))?;
file.write_all(machine_id.to_string().as_bytes())
.with_context(|| format!("failed to write {}", tmp_path.display()))?;
file.sync_all()
.with_context(|| format!("failed to flush {}", tmp_path.display()))?;
}
if let Err(err) = std::fs::rename(&tmp_path, path) {
let _ = std::fs::remove_file(&tmp_path);
return Err(err).with_context(|| {
format!(
"failed to move machine id state file into place at {}",
path.display()
)
});
}
Ok(())
}
struct MachineIdWriteLock {
#[cfg(unix)]
_lock: Flock<std::fs::File>,
#[cfg(not(unix))]
path: PathBuf,
}
impl MachineIdWriteLock {
fn acquire(path: &Path) -> anyhow::Result<Self> {
let parent = path.parent().ok_or_else(|| {
anyhow::anyhow!(
"machine id state file {} has no parent directory",
path.display()
)
})?;
std::fs::create_dir_all(parent).with_context(|| {
format!(
"failed to create machine id state directory {}",
parent.display()
)
})?;
#[cfg(unix)]
{
Self::acquire_unix(path)
}
#[cfg(not(unix))]
{
Self::acquire_fallback(path)
}
}
#[cfg(unix)]
fn acquire_unix(path: &Path) -> anyhow::Result<Self> {
let lock_path = path.with_extension("lock");
let deadline = Instant::now() + Duration::from_secs(5);
let mut lock_file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)
.with_context(|| format!("failed to open machine id lock {}", lock_path.display()))?;
loop {
match Flock::lock(lock_file, FlockArg::LockExclusiveNonblock) {
Ok(lock) => return Ok(Self { _lock: lock }),
Err((file, Errno::EAGAIN)) => {
if Instant::now() >= deadline {
anyhow::bail!(
"timed out waiting for machine id lock {}",
lock_path.display()
);
}
lock_file = file;
std::thread::sleep(Duration::from_millis(50));
}
Err((_file, err)) => {
anyhow::bail!(
"failed to acquire machine id lock {}: {}",
lock_path.display(),
err
);
}
}
}
}
#[cfg(not(unix))]
fn acquire_fallback(path: &Path) -> anyhow::Result<Self> {
let lock_path = path.with_extension("lock");
let deadline = Instant::now() + Duration::from_secs(5);
loop {
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&lock_path)
{
Ok(mut file) => {
writeln!(file, "pid={}", std::process::id()).ok();
return Ok(Self { path: lock_path });
}
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
if should_reap_stale_lock_file(&lock_path) {
let _ = std::fs::remove_file(&lock_path);
continue;
}
if Instant::now() >= deadline {
anyhow::bail!(
"timed out waiting for machine id lock {}",
lock_path.display()
);
}
std::thread::sleep(Duration::from_millis(50));
}
Err(err) => {
return Err(err).with_context(|| {
format!("failed to acquire machine id lock {}", lock_path.display())
});
}
}
}
}
}
#[cfg(not(unix))]
fn should_reap_stale_lock_file(lock_path: &Path) -> bool {
const STALE_LOCK_AGE: Duration = Duration::from_secs(30);
let Ok(metadata) = std::fs::metadata(lock_path) else {
return false;
};
let Ok(modified) = metadata.modified() else {
return false;
};
modified
.elapsed()
.is_ok_and(|elapsed| elapsed >= STALE_LOCK_AGE)
}
impl Drop for MachineIdWriteLock {
fn drop(&mut self) {
#[cfg(not(unix))]
let _ = std::fs::remove_file(&self.path);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resolve_machine_id_uses_uuid_seed_verbatim() {
let raw = "33333333-3333-3333-3333-333333333333".to_string();
let opts = MachineIdOptions {
explicit_machine_id: Some(raw.clone()),
state_dir: None,
};
assert_eq!(
resolve_machine_id(&opts).unwrap(),
uuid::Uuid::parse_str(&raw).unwrap()
);
}
#[test]
fn test_resolve_machine_id_reads_state_file() {
let temp_dir = tempfile::tempdir().unwrap();
let expected = uuid::Uuid::new_v4();
std::fs::write(temp_dir.path().join("machine_id"), expected.to_string()).unwrap();
let opts = MachineIdOptions {
explicit_machine_id: None,
state_dir: Some(temp_dir.path().to_path_buf()),
};
assert_eq!(resolve_machine_id(&opts).unwrap(), expected);
}
#[test]
fn test_read_legacy_machine_id_file_ignores_read_errors() {
let temp_dir = tempfile::tempdir().unwrap();
assert_eq!(read_legacy_machine_id_file_at(temp_dir.path()), None);
}
#[test]
fn test_write_uuid_file_atomically_writes_expected_contents() {
let temp_dir = tempfile::tempdir().unwrap();
let machine_id = uuid::Uuid::new_v4();
let state_file = temp_dir.path().join("machine_id");
write_uuid_file_atomically(&state_file, machine_id).unwrap();
assert_eq!(
std::fs::read_to_string(state_file).unwrap(),
machine_id.to_string()
);
}
#[test]
fn test_non_empty_os_string_filters_empty_values() {
assert_eq!(non_empty_os_string(Some(OsString::new())), None);
assert_eq!(
non_empty_os_string(Some(OsString::from("foo"))),
Some(OsString::from("foo"))
);
}
#[cfg(target_os = "linux")]
#[test]
fn test_default_linux_machine_id_state_dir_falls_back_in_order() {
assert_eq!(
default_linux_machine_id_state_dir(
Some(OsString::from("/tmp/xdg")),
Some(OsString::from("/tmp/home"))
),
PathBuf::from("/tmp/xdg").join("easytier")
);
assert_eq!(
default_linux_machine_id_state_dir(
Some(OsString::new()),
Some(OsString::from("/tmp/home"))
),
PathBuf::from("/tmp/home")
.join(".local")
.join("share")
.join("easytier")
);
assert_eq!(
default_linux_machine_id_state_dir(Some(OsString::new()), Some(OsString::new())),
PathBuf::from("/var/lib/easytier")
);
}
#[test]
fn test_persist_machine_id_creates_missing_state_dir() {
let temp_dir = tempfile::tempdir().unwrap();
let state_file = temp_dir.path().join("nested").join("machine_id");
let machine_id = uuid::Uuid::new_v4();
assert_eq!(
persist_machine_id(&state_file, machine_id).unwrap(),
machine_id
);
assert_eq!(
std::fs::read_to_string(state_file).unwrap(),
machine_id.to_string()
);
}
#[test]
fn test_legacy_machine_uid_migration_requires_existing_state_dir_content() {
let temp_dir = tempfile::tempdir().unwrap();
let missing_state_file = temp_dir.path().join("missing").join("machine_id");
assert!(!should_attempt_legacy_machine_uid_migration(
&missing_state_file
));
let empty_dir = temp_dir.path().join("empty");
std::fs::create_dir_all(&empty_dir).unwrap();
assert!(!should_attempt_legacy_machine_uid_migration(
&empty_dir.join("machine_id")
));
std::fs::write(empty_dir.join("config.toml"), "x=1").unwrap();
assert!(should_attempt_legacy_machine_uid_migration(
&empty_dir.join("machine_id")
));
}
}
+4 -79
View File
@@ -1,36 +1,34 @@
use std::{
fmt::Debug,
future,
io::Write as _,
sync::{Arc, Mutex},
};
use time::util::refresh_tz;
use tokio::{task::JoinSet, time::timeout};
use tracing::Instrument;
use crate::{set_global_var, use_global_var};
pub mod acl_processor;
pub mod compressor;
pub mod config;
pub mod constants;
pub mod defer;
pub mod dns;
pub mod env_parser;
pub mod error;
pub mod global_ctx;
pub mod idn;
pub mod ifcfg;
pub mod log;
pub mod machine_id;
pub mod netns;
pub mod network;
pub mod os_info;
pub mod scoped_task;
pub mod stats_manager;
pub mod stun;
pub mod stun_codec_ext;
pub mod token_bucket;
pub mod tracing_rolling_appender;
pub mod upnp;
pub use machine_id::{MachineIdOptions, resolve_machine_id};
pub fn get_logger_timer<F: time::formatting::Formattable>(
format: F,
@@ -97,71 +95,6 @@ pub fn join_joinset_background<T: Debug + Send + Sync + 'static>(
);
}
pub fn set_default_machine_id(mid: Option<String>) {
set_global_var!(MACHINE_UID, mid);
}
pub fn get_machine_id() -> uuid::Uuid {
if let Some(default_mid) = use_global_var!(MACHINE_UID) {
if let Ok(mid) = uuid::Uuid::parse_str(default_mid.trim()) {
return mid;
}
let mut b = [0u8; 16];
crate::tunnel::generate_digest_from_str("", &default_mid, &mut b);
return uuid::Uuid::from_bytes(b);
}
// a path same as the binary
let machine_id_file = std::env::current_exe()
.map(|x| x.with_file_name("et_machine_id"))
.unwrap_or_else(|_| std::path::PathBuf::from("et_machine_id"));
// try load from local file
if let Ok(mid) = std::fs::read_to_string(&machine_id_file)
&& let Ok(mid) = uuid::Uuid::parse_str(mid.trim())
{
return mid;
}
#[cfg(any(
target_os = "linux",
all(target_os = "macos", not(feature = "macos-ne")),
target_os = "windows",
target_os = "freebsd"
))]
let gen_mid = machine_uid::get()
.map(|x| {
if x.is_empty() {
return uuid::Uuid::new_v4();
}
let mut b = [0u8; 16];
crate::tunnel::generate_digest_from_str("", x.as_str(), &mut b);
uuid::Uuid::from_bytes(b)
})
.ok();
#[cfg(not(any(
target_os = "linux",
all(target_os = "macos", not(feature = "macos-ne")),
target_os = "windows",
target_os = "freebsd"
)))]
let gen_mid = None;
if let Some(mid) = gen_mid {
return mid;
}
let gen_mid = uuid::Uuid::new_v4();
// try save to local file
if let Ok(mut file) = std::fs::File::create(machine_id_file) {
let _ = file.write_all(gen_mid.to_string().as_bytes());
}
gen_mid
}
pub fn shrink_dashmap<K: Eq + std::hash::Hash, V>(
map: &dashmap::DashMap<K, V>,
threshold: Option<usize>,
@@ -211,12 +144,4 @@ mod tests {
assert_eq!(weak_js.weak_count(), 0);
assert_eq!(weak_js.strong_count(), 0);
}
#[test]
fn test_get_machine_id_uses_uuid_seed_verbatim() {
let raw = "33333333-3333-3333-3333-333333333333".to_string();
set_default_machine_id(Some(raw.clone()));
assert_eq!(get_machine_id(), uuid::Uuid::parse_str(&raw).unwrap());
set_default_machine_id(None);
}
}
-119
View File
@@ -1,119 +0,0 @@
//! This crate provides a wrapper type of Tokio's JoinHandle: `ScopedTask`, which aborts the task when it's dropped.
//! `ScopedTask` can still be awaited to join the child-task, and abort-on-drop will still trigger while it is being awaited.
//!
//! For example, if task A spawned task B but is doing something else, and task B is waiting for task C to join,
//! aborting A will also abort both B and C.
use derive_more::{Deref, DerefMut, From};
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use tokio::task::JoinHandle;
#[derive(Debug, From, Deref, DerefMut)]
pub struct ScopedTask<T>(JoinHandle<T>);
impl<T> Drop for ScopedTask<T> {
fn drop(&mut self) {
self.abort()
}
}
impl<T> Future for ScopedTask<T> {
type Output = <JoinHandle<T> as Future>::Output;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
Pin::new(&mut self.0).poll(cx)
}
}
#[cfg(test)]
mod tests {
use super::ScopedTask;
use futures_util::future::pending;
use std::sync::{Arc, RwLock};
use tokio::task::yield_now;
struct Sentry(Arc<RwLock<bool>>);
impl Drop for Sentry {
fn drop(&mut self) {
*self.0.write().unwrap() = true
}
}
#[tokio::test]
async fn drop_while_not_waiting_for_join() {
let dropped = Arc::new(RwLock::new(false));
let sentry = Sentry(dropped.clone());
let task = ScopedTask::from(tokio::spawn(async move {
let _sentry = sentry;
pending::<()>().await
}));
yield_now().await;
assert!(!*dropped.read().unwrap());
drop(task);
yield_now().await;
assert!(*dropped.read().unwrap());
}
#[tokio::test]
async fn drop_while_waiting_for_join() {
let dropped = Arc::new(RwLock::new(false));
let sentry = Sentry(dropped.clone());
let handle = tokio::spawn(async move {
ScopedTask::from(tokio::spawn(async move {
let _sentry = sentry;
pending::<()>().await
}))
.await
.unwrap()
});
yield_now().await;
assert!(!*dropped.read().unwrap());
handle.abort();
yield_now().await;
assert!(*dropped.read().unwrap());
}
#[tokio::test]
async fn no_drop_only_join() {
assert_eq!(
ScopedTask::from(tokio::spawn(async {
yield_now().await;
5
}))
.await
.unwrap(),
5
)
}
#[tokio::test]
async fn manually_abort_before_drop() {
let dropped = Arc::new(RwLock::new(false));
let sentry = Sentry(dropped.clone());
let task = ScopedTask::from(tokio::spawn(async move {
let _sentry = sentry;
pending::<()>().await
}));
yield_now().await;
assert!(!*dropped.read().unwrap());
task.abort();
yield_now().await;
assert!(*dropped.read().unwrap());
}
#[tokio::test]
async fn manually_abort_then_join() {
let dropped = Arc::new(RwLock::new(false));
let sentry = Sentry(dropped.clone());
let task = ScopedTask::from(tokio::spawn(async move {
let _sentry = sentry;
pending::<()>().await
}));
yield_now().await;
assert!(!*dropped.read().unwrap());
task.abort();
yield_now().await;
assert!(task.await.is_err());
}
}
+25 -4
View File
@@ -5,8 +5,7 @@ use std::fmt;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::time::interval;
use crate::common::scoped_task::ScopedTask;
use tokio_util::task::AbortOnDropHandle;
/// Predefined metric names for type safety
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -86,6 +85,15 @@ pub enum MetricName {
/// Traffic packets forwarded for foreign network, forward
TrafficPacketsForeignForwardForwarded,
/// UDP broadcast relay packets captured from the raw socket
UdpBroadcastRelayPacketsCaptured,
/// UDP broadcast relay packets ignored before forwarding
UdpBroadcastRelayPacketsIgnored,
/// UDP broadcast relay packets forwarded
UdpBroadcastRelayPacketsForwarded,
/// UDP broadcast relay packets that failed to forward
UdpBroadcastRelayPacketsForwardFailed,
/// Compression bytes before compression
CompressionBytesRxBefore,
/// Compression bytes after compression
@@ -168,6 +176,19 @@ impl fmt::Display for MetricName {
write!(f, "traffic_packets_foreign_forward_forwarded")
}
MetricName::UdpBroadcastRelayPacketsCaptured => {
write!(f, "udp_broadcast_relay_packets_captured")
}
MetricName::UdpBroadcastRelayPacketsIgnored => {
write!(f, "udp_broadcast_relay_packets_ignored")
}
MetricName::UdpBroadcastRelayPacketsForwarded => {
write!(f, "udp_broadcast_relay_packets_forwarded")
}
MetricName::UdpBroadcastRelayPacketsForwardFailed => {
write!(f, "udp_broadcast_relay_packets_forward_failed")
}
MetricName::CompressionBytesRxBefore => write!(f, "compression_bytes_rx_before"),
MetricName::CompressionBytesRxAfter => write!(f, "compression_bytes_rx_after"),
MetricName::CompressionBytesTxBefore => write!(f, "compression_bytes_tx_before"),
@@ -578,7 +599,7 @@ impl MetricSnapshot {
/// StatsManager manages global statistics with high performance counters
pub struct StatsManager {
counters: Arc<DashMap<MetricKey, Arc<MetricData>>>,
cleanup_task: ScopedTask<()>,
cleanup_task: AbortOnDropHandle<()>,
}
impl StatsManager {
@@ -611,7 +632,7 @@ impl StatsManager {
Self {
counters,
cleanup_task: cleanup_task.into(),
cleanup_task: AbortOnDropHandle::new(cleanup_task),
}
}
+103 -36
View File
@@ -11,7 +11,7 @@ use crossbeam::atomic::AtomicCell;
use rand::seq::IteratorRandom;
use socket2::{SockAddr, SockRef};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{UdpSocket, lookup_host};
use tokio::net::UdpSocket;
use tokio::sync::{Mutex, broadcast};
use tokio::task::JoinSet;
use tracing::{Instrument, Level};
@@ -20,10 +20,9 @@ use bytecodec::{DecodeExt, EncodeExt};
use stun_codec::rfc5389::methods::BINDING;
use stun_codec::{Message, MessageClass, MessageDecoder, MessageEncoder};
use crate::common::error::Error;
use super::dns::resolve_txt_record;
use super::stun_codec_ext::*;
use crate::common::error::Error;
use crate::utils::dns::{resolve_host, txt_resolve};
const DEFAULT_UDP_STUN_SERVERS: &[&str] = &[
"txt:stun.easytier.cn",
@@ -61,9 +60,16 @@ impl HostResolverIter {
}
}
async fn get_txt_record(domain_name: &str) -> Result<Vec<String>, Error> {
let txt_data = resolve_txt_record(domain_name).await?;
Ok(txt_data.split(" ").map(|x| x.to_string()).collect())
fn parse_ipv6_socket_addr_without_brackets(host: &str) -> Option<SocketAddr> {
if host.parse::<IpAddr>().is_ok() {
return None;
}
let (ip, port) = host.rsplit_once(':')?;
Some(SocketAddr::new(
IpAddr::V6(ip.parse().ok()?),
port.parse().ok()?,
))
}
#[async_recursion::async_recursion]
@@ -74,15 +80,10 @@ impl HostResolverIter {
}
let host = self.hostnames.remove(0);
let host = if host.contains(':') {
host
} else {
format!("{}:3478", host)
};
if host.starts_with("txt:") {
let domain_name = host.trim_start_matches("txt:");
match Self::get_txt_record(domain_name).await {
match txt_resolve(domain_name).await {
Ok(hosts) => {
tracing::info!(
?domain_name,
@@ -104,22 +105,53 @@ impl HostResolverIter {
}
let use_ipv6 = self.use_ipv6;
match lookup_host(&host).await {
Ok(ips) => {
self.ips = ips
.filter(|x| if use_ipv6 { x.is_ipv6() } else { x.is_ipv4() })
.choose_multiple(&mut rand::thread_rng(), self.max_ip_per_domain as usize);
if self.ips.is_empty() {
return self.next().await;
}
if let Ok(addr) = host.parse::<SocketAddr>() {
if (use_ipv6 && addr.is_ipv6()) || (!use_ipv6 && addr.is_ipv4()) {
self.ips = vec![addr];
}
Err(e) => {
tracing::warn!(?host, ?e, "lookup host for stun failed");
if self.ips.is_empty() {
return self.next().await;
}
};
} else if let Some(addr) = Self::parse_ipv6_socket_addr_without_brackets(&host) {
if use_ipv6 {
self.ips = vec![addr];
}
if self.ips.is_empty() {
return self.next().await;
}
} else {
let (host, port) = if let Ok(ip) = host.parse::<IpAddr>() {
(ip.to_string(), 3478)
} else if let Ok(url) = url::Url::parse(&format!("stun://{}", host)) {
let Some(parsed_host) = url.host_str() else {
tracing::warn!(?host, "parse stun host failed");
return self.next().await;
};
(parsed_host.to_string(), url.port().unwrap_or(3478))
} else {
(host, 3478)
};
match resolve_host(&host, port).await {
Ok(ips) => {
self.ips = ips
.into_iter()
.filter(|x| if use_ipv6 { x.is_ipv6() } else { x.is_ipv4() })
.choose_multiple(
&mut rand::thread_rng(),
self.max_ip_per_domain as usize,
);
if self.ips.is_empty() {
return self.next().await;
}
}
Err(e) => {
tracing::warn!(?host, ?e, "resolve host for stun failed");
return self.next().await;
}
};
}
}
Some(self.ips.remove(0))
@@ -276,7 +308,6 @@ impl StunClient {
let stun_host = self.stun_server;
// repeat req in case of packet loss
let mut tids = vec![];
for _ in 0..self.req_repeat {
let tid = rand::random::<u32>();
// let tid = 1;
@@ -912,6 +943,10 @@ impl TcpNatTypeDetector {
pub trait StunInfoCollectorTrait: Send + Sync {
fn get_stun_info(&self) -> StunInfo;
async fn get_udp_port_mapping(&self, local_port: u16) -> Result<SocketAddr, Error>;
async fn get_udp_port_mapping_with_socket(
&self,
udp: Arc<UdpSocket>,
) -> Result<SocketAddr, Error>;
async fn get_tcp_port_mapping(&self, local_port: u16) -> Result<SocketAddr, Error>;
}
@@ -975,6 +1010,14 @@ impl StunInfoCollectorTrait for StunInfoCollector {
}
async fn get_udp_port_mapping(&self, local_port: u16) -> Result<SocketAddr, Error> {
let udp = Arc::new(UdpSocket::bind(format!("0.0.0.0:{}", local_port)).await?);
self.get_udp_port_mapping_with_socket(udp).await
}
async fn get_udp_port_mapping_with_socket(
&self,
udp: Arc<UdpSocket>,
) -> Result<SocketAddr, Error> {
self.start_stun_routine();
let mut stun_servers = self
@@ -1000,7 +1043,6 @@ impl StunInfoCollectorTrait for StunInfoCollector {
return Err(Error::NotFound);
}
let udp = Arc::new(UdpSocket::bind(format!("0.0.0.0:{}", local_port)).await?);
let mut client_builder = StunClientBuilder::new(udp.clone());
for server in stun_servers.iter() {
@@ -1316,6 +1358,13 @@ impl StunInfoCollectorTrait for MockStunInfoCollector {
Ok(format!("127.0.0.1:{}", port).parse().unwrap())
}
async fn get_udp_port_mapping_with_socket(
&self,
udp: Arc<UdpSocket>,
) -> Result<std::net::SocketAddr, Error> {
self.get_udp_port_mapping(udp.local_addr()?.port()).await
}
async fn get_tcp_port_mapping(&self, mut port: u16) -> Result<std::net::SocketAddr, Error> {
if port == 0 {
port = 40144;
@@ -1326,14 +1375,32 @@ impl StunInfoCollectorTrait for MockStunInfoCollector {
#[cfg(test)]
mod tests {
use crate::{
common::scoped_task::ScopedTask,
tunnel::{TunnelListener, udp::UdpTunnelListener},
};
use crate::tunnel::{TunnelListener, udp::UdpTunnelListener};
use tokio::time::{sleep, timeout};
use tokio_util::task::AbortOnDropHandle;
use super::*;
#[test]
fn parse_ipv6_socket_addr_without_brackets_rejects_plain_ipv6_literals() {
assert_eq!(
HostResolverIter::parse_ipv6_socket_addr_without_brackets("2001:db8::1"),
None
);
assert_eq!(
HostResolverIter::parse_ipv6_socket_addr_without_brackets("2001:db8:0:0:0:0:0:1"),
None
);
}
#[test]
fn parse_ipv6_socket_addr_without_brackets_accepts_unambiguous_port() {
assert_eq!(
HostResolverIter::parse_ipv6_socket_addr_without_brackets("::1:55355"),
Some("[::1]:55355".parse().unwrap())
);
}
#[tokio::test]
async fn test_udp_nat_type_detector() {
let collector = StunInfoCollector::new(
@@ -1424,7 +1491,7 @@ mod tests {
use stun_codec::rfc5389::attributes::XorMappedAddress;
use tokio::net::TcpListener;
async fn spawn_tcp_stun_server() -> (SocketAddr, ScopedTask<()>) {
async fn spawn_tcp_stun_server() -> (SocketAddr, AbortOnDropHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let server_addr = listener.local_addr().unwrap();
@@ -1448,7 +1515,7 @@ mod tests {
stream.write_all(rsp_buf.as_slice()).await.unwrap();
});
(server_addr, task.into())
(server_addr, AbortOnDropHandle::new(task))
}
let (server1, _t1) = spawn_tcp_stun_server().await;
@@ -1487,7 +1554,7 @@ mod tests {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let server_addr = listener.local_addr().unwrap();
let _t = ScopedTask::from(tokio::spawn(async move {
let _t = AbortOnDropHandle::new(tokio::spawn(async move {
for _ in 0..8 {
let Ok((mut stream, peer_addr)) = listener.accept().await else {
break;
@@ -1548,6 +1615,6 @@ mod tests {
});
let stun_servers = vec!["::1:55355".to_string()];
let ret = StunInfoCollector::get_public_ipv6(&stun_servers).await;
println!("{:#?}", ret);
assert_eq!(ret, Some(Ipv6Addr::LOCALHOST));
}
}
+5 -5
View File
@@ -5,8 +5,8 @@ use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tokio::sync::Notify;
use tokio::time;
use tokio_util::task::AbortOnDropHandle;
use crate::common::scoped_task::ScopedTask;
use crate::proto::common::LimiterConfig;
/// Token Bucket rate limiter using atomic operations
@@ -14,7 +14,7 @@ pub struct TokenBucket {
available_tokens: AtomicU64, // Current token count (atomic)
last_refill_time: AtomicU64, // Last refill time as micros since epoch
config: BucketConfig, // Immutable configuration
refill_task: Mutex<Option<ScopedTask<()>>>, // Background refill task
refill_task: Mutex<Option<AbortOnDropHandle<()>>>, // Background refill task
start_time: Instant, // Bucket creation time
refill_notifier: Arc<Notify>,
@@ -91,7 +91,7 @@ impl TokenBucket {
.refill_task
.lock()
.unwrap()
.replace(refill_task.into());
.replace(AbortOnDropHandle::new(refill_task));
arc_self
}
@@ -172,7 +172,7 @@ impl TokenBucket {
pub struct TokenBucketManager {
buckets: Arc<DashMap<String, Arc<TokenBucket>>>,
retain_task: ScopedTask<()>,
retain_task: AbortOnDropHandle<()>,
}
impl Default for TokenBucketManager {
@@ -205,7 +205,7 @@ impl TokenBucketManager {
Self {
buckets,
retain_task: retain_task.into(),
retain_task: AbortOnDropHandle::new(retain_task),
}
}
+786
View File
@@ -0,0 +1,786 @@
use std::{
fmt,
net::{Ipv4Addr, SocketAddr, SocketAddrV4},
sync::Arc,
time::Duration,
};
#[cfg(test)]
use std::sync::atomic::{AtomicUsize, Ordering};
use anyhow::{Context, anyhow, bail};
use igd_next::{
AddAnyPortError, PortMappingProtocol, SearchOptions,
aio::{
Gateway,
tokio::{Tokio, search_gateway},
},
};
use natpmp::{
Protocol as NatPmpProtocol, Response as NatPmpResponse, new_tokio_natpmp, new_tokio_natpmp_with,
};
use tokio::{net::UdpSocket, sync::oneshot};
use super::{
global_ctx::{ArcGlobalCtx, GlobalCtxEvent},
stun::StunInfoCollectorTrait as _,
};
use crate::tunnel::build_url_from_socket_addr;
const UPNP_SEARCH_TIMEOUT: Duration = Duration::from_secs(1);
const UPNP_SEARCH_RESPONSE_TIMEOUT: Duration = Duration::from_millis(300);
const NAT_PMP_RESPONSE_TIMEOUT: Duration = Duration::from_secs(1);
const UPNP_LEASE_DURATION_SECS: u32 = 300;
const UPNP_RENEW_INTERVAL: Duration = Duration::from_secs(240);
const UPNP_DESCRIPTION: &str = "EasyTier udp hole punch";
const PORT_MAPPING_BACKEND_NAT_PMP: &str = "nat-pmp";
const PORT_MAPPING_BACKEND_IGD: &str = "igd";
type TokioGateway = Gateway<Tokio>;
#[cfg(test)]
static UDP_PORT_MAPPING_ATTEMPTS: AtomicUsize = AtomicUsize::new(0);
#[cfg(test)]
pub(crate) fn reset_udp_port_mapping_attempts_for_test() {
UDP_PORT_MAPPING_ATTEMPTS.store(0, Ordering::Relaxed);
}
#[cfg(test)]
pub(crate) fn udp_port_mapping_attempts_for_test() -> usize {
UDP_PORT_MAPPING_ATTEMPTS.load(Ordering::Relaxed)
}
enum PortMappingBackend {
NatPmp { gateway: Ipv4Addr },
Igd { gateway: TokioGateway },
}
impl PortMappingBackend {
fn name(&self) -> &'static str {
match self {
Self::NatPmp { .. } => PORT_MAPPING_BACKEND_NAT_PMP,
Self::Igd { .. } => PORT_MAPPING_BACKEND_IGD,
}
}
}
struct ActiveUdpPortMapping {
backend: PortMappingBackend,
local_listener: url::Url,
local_addr: SocketAddr,
gateway_external_port: u16,
}
impl ActiveUdpPortMapping {
async fn discover_nat_pmp_gateway(
local_listener: &url::Url,
) -> anyhow::Result<(Ipv4Addr, SocketAddr)> {
let client = new_tokio_natpmp().await.context("create nat-pmp client")?;
let gateway = *client.gateway();
let gateway_addr = SocketAddr::V4(SocketAddrV4::new(gateway, natpmp::NATPMP_PORT));
let local_addr = resolve_internal_addr(gateway_addr, local_listener).await?;
Ok((gateway, local_addr))
}
async fn establish_via_nat_pmp(
local_listener: &url::Url,
gateway: Ipv4Addr,
local_addr: SocketAddr,
) -> anyhow::Result<Self> {
let gateway_external_port =
add_udp_mapping_port_nat_pmp(gateway, local_addr, local_listener)
.await
.with_context(|| {
format!("map udp socket for {local_listener} via nat-pmp gateway {gateway}")
})?;
Ok(Self {
backend: PortMappingBackend::NatPmp { gateway },
local_listener: local_listener.clone(),
local_addr,
gateway_external_port,
})
}
async fn discover_igd_gateway(
global_ctx: &ArcGlobalCtx,
local_listener: &url::Url,
) -> anyhow::Result<(TokioGateway, SocketAddr)> {
let _g = global_ctx.net_ns.guard();
let gateway = search_gateway(SearchOptions {
timeout: Some(UPNP_SEARCH_TIMEOUT),
single_search_timeout: Some(UPNP_SEARCH_RESPONSE_TIMEOUT),
..Default::default()
})
.await
.with_context(|| format!("search igd gateway for {local_listener}"))?;
let local_addr = resolve_internal_addr(gateway.addr, local_listener).await?;
Ok((gateway, local_addr))
}
async fn establish_via_igd(
local_listener: &url::Url,
gateway: TokioGateway,
local_addr: SocketAddr,
) -> anyhow::Result<Self> {
let gateway_external_port = add_udp_mapping_port_igd(&gateway, local_addr, local_listener)
.await
.with_context(|| {
format!(
"map udp socket for {local_listener} via gateway {}",
gateway.addr
)
})?;
Ok(Self {
backend: PortMappingBackend::Igd { gateway },
local_listener: local_listener.clone(),
local_addr,
gateway_external_port,
})
}
fn backend_name(&self) -> &'static str {
self.backend.name()
}
async fn renew(&self) -> anyhow::Result<()> {
match &self.backend {
PortMappingBackend::NatPmp { gateway } => {
renew_udp_mapping_nat_pmp(
*gateway,
self.local_addr,
self.gateway_external_port,
&self.local_listener,
)
.await
}
PortMappingBackend::Igd { gateway } => {
renew_udp_mapping_igd(
gateway,
self.local_addr,
self.gateway_external_port,
&self.local_listener,
)
.await
}
}
}
async fn remove(&self) -> anyhow::Result<()> {
match &self.backend {
PortMappingBackend::NatPmp { gateway } => {
remove_udp_mapping_nat_pmp(
*gateway,
self.local_addr,
self.gateway_external_port,
&self.local_listener,
)
.await
}
PortMappingBackend::Igd { gateway } => {
remove_udp_mapping_igd(gateway, self.gateway_external_port, &self.local_listener)
.await
}
}
}
}
pub struct UdpPortMappingLease {
backend: &'static str,
gateway_external_port: u16,
stop_tx: Option<oneshot::Sender<()>>,
}
impl UdpPortMappingLease {
pub fn backend(&self) -> &'static str {
self.backend
}
pub fn gateway_external_port(&self) -> u16 {
self.gateway_external_port
}
}
impl fmt::Debug for UdpPortMappingLease {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("UdpPortMappingLease")
.field("backend", &self.backend)
.field("gateway_external_port", &self.gateway_external_port)
.finish()
}
}
impl Drop for UdpPortMappingLease {
fn drop(&mut self) {
if let Some(stop_tx) = self.stop_tx.take() {
let _ = stop_tx.send(());
}
}
}
pub async fn resolve_udp_public_addr(
global_ctx: ArcGlobalCtx,
local_listener: &url::Url,
socket: Arc<UdpSocket>,
) -> anyhow::Result<(SocketAddr, Option<UdpPortMappingLease>)> {
let port_mapping = match try_start_udp_port_mapping(&global_ctx, local_listener).await {
Ok(mapping) => mapping,
Err(err) => {
tracing::warn!(
?err,
%local_listener,
"failed to establish udp port mapping, fallback to stun-only public addr resolution"
);
None
}
};
let mapped_addr = global_ctx
.get_stun_info_collector()
.get_udp_port_mapping_with_socket(socket)
.await
.map_err(anyhow::Error::from)
.with_context(|| format!("resolve udp public addr for {local_listener}"))?;
if let Some(port_mapping) = port_mapping.as_ref() {
let mapped_listener = build_url_from_socket_addr(&mapped_addr.to_string(), "udp");
global_ctx.issue_event(GlobalCtxEvent::ListenerPortMappingEstablished {
local_listener: local_listener.clone(),
mapped_listener,
backend: port_mapping.backend().to_string(),
});
tracing::info!(
%local_listener,
backend = port_mapping.backend(),
gateway_external_port = port_mapping.gateway_external_port(),
stun_mapped_addr = %mapped_addr,
"udp public addr resolved after port mapping"
);
} else {
tracing::debug!(
%local_listener,
stun_mapped_addr = %mapped_addr,
"udp public addr resolved without port mapping"
);
}
Ok((mapped_addr, port_mapping))
}
async fn try_start_udp_port_mapping(
global_ctx: &ArcGlobalCtx,
local_listener: &url::Url,
) -> anyhow::Result<Option<UdpPortMappingLease>> {
if global_ctx.get_flags().disable_upnp || !should_map_udp_listener(local_listener) {
return Ok(None);
}
#[cfg(test)]
UDP_PORT_MAPPING_ATTEMPTS.fetch_add(1, Ordering::Relaxed);
let mapping = discover_udp_port_mapping(global_ctx.clone(), local_listener.clone()).await?;
tracing::info!(
%local_listener,
backend = mapping.backend_name(),
local_addr = %mapping.local_addr,
gateway_external_port = mapping.gateway_external_port,
"udp port mapping established"
);
let backend = mapping.backend_name();
let gateway_external_port = mapping.gateway_external_port;
let runtime_global_ctx = global_ctx.clone();
let runtime_local_listener = local_listener.clone();
let (stop_tx, stop_rx) = oneshot::channel();
if should_run_port_mapping_in_dedicated_thread(&runtime_global_ctx) {
tokio::task::spawn_blocking(move || {
let _g = runtime_global_ctx.net_ns.guard();
match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(runtime) => {
runtime.block_on(run_udp_port_mapping_task(
runtime_local_listener,
mapping,
stop_rx,
));
}
Err(err) => {
tracing::error!(
?err,
%runtime_local_listener,
"failed to build runtime for udp port mapping renew task"
);
}
}
});
} else {
tokio::spawn(run_udp_port_mapping_task(
runtime_local_listener,
mapping,
stop_rx,
));
}
Ok(Some(UdpPortMappingLease {
backend,
gateway_external_port,
stop_tx: Some(stop_tx),
}))
}
async fn discover_udp_port_mapping(
global_ctx: ArcGlobalCtx,
local_listener: url::Url,
) -> anyhow::Result<ActiveUdpPortMapping> {
match discover_igd_gateway_in_netns(global_ctx.clone(), local_listener.clone()).await {
Ok((gateway, local_addr)) => match establish_igd_mapping_in_netns(
global_ctx.clone(),
local_listener.clone(),
gateway,
local_addr,
)
.await
{
Ok(mapping) => Ok(mapping),
Err(igd_err) => {
tracing::debug!(
?igd_err,
%local_listener,
"igd udp port mapping failed, retry with nat-pmp"
);
match discover_nat_pmp_gateway_in_netns(global_ctx.clone(), local_listener.clone())
.await
{
Ok((gateway, local_addr)) => establish_nat_pmp_mapping_in_netns(
global_ctx,
local_listener.clone(),
gateway,
local_addr,
)
.await
.map_err(|nat_pmp_err| {
anyhow!(
"udp port mapping failed for {local_listener}: igd error: {igd_err}; nat-pmp error: {nat_pmp_err}"
)
}),
Err(nat_pmp_err) => Err(anyhow!(
"udp port mapping failed for {local_listener}: igd error: {igd_err}; nat-pmp discovery error: {nat_pmp_err}"
)),
}
}
},
Err(igd_err) => {
tracing::debug!(
?igd_err,
%local_listener,
"igd gateway discovery failed, retry with nat-pmp"
);
match discover_nat_pmp_gateway_in_netns(global_ctx.clone(), local_listener.clone()).await
{
Ok((gateway, local_addr)) => establish_nat_pmp_mapping_in_netns(
global_ctx,
local_listener.clone(),
gateway,
local_addr,
)
.await
.map_err(|nat_pmp_err| {
anyhow!(
"udp port mapping failed for {local_listener}: igd discovery error: {igd_err}; nat-pmp error: {nat_pmp_err}"
)
}),
Err(nat_pmp_err) => Err(anyhow!(
"udp port mapping failed for {local_listener}: igd discovery error: {igd_err}; nat-pmp discovery error: {nat_pmp_err}"
)),
}
}
}
}
async fn discover_igd_gateway_in_netns(
global_ctx: ArcGlobalCtx,
local_listener: url::Url,
) -> anyhow::Result<(TokioGateway, SocketAddr)> {
if !should_run_port_mapping_in_dedicated_thread(&global_ctx) {
return ActiveUdpPortMapping::discover_igd_gateway(&global_ctx, &local_listener).await;
}
tokio::task::spawn_blocking(move || {
let _g = global_ctx.net_ns.guard();
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("build runtime for igd gateway discovery")?
.block_on(ActiveUdpPortMapping::discover_igd_gateway(
&global_ctx,
&local_listener,
))
})
.await
.context("join igd gateway discovery task")?
}
async fn establish_igd_mapping_in_netns(
global_ctx: ArcGlobalCtx,
local_listener: url::Url,
gateway: TokioGateway,
local_addr: SocketAddr,
) -> anyhow::Result<ActiveUdpPortMapping> {
if !should_run_port_mapping_in_dedicated_thread(&global_ctx) {
return ActiveUdpPortMapping::establish_via_igd(&local_listener, gateway, local_addr).await;
}
tokio::task::spawn_blocking(move || {
let _g = global_ctx.net_ns.guard();
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("build runtime for igd mapping establishment")?
.block_on(ActiveUdpPortMapping::establish_via_igd(
&local_listener,
gateway,
local_addr,
))
})
.await
.context("join igd mapping establishment task")?
}
async fn discover_nat_pmp_gateway_in_netns(
global_ctx: ArcGlobalCtx,
local_listener: url::Url,
) -> anyhow::Result<(Ipv4Addr, SocketAddr)> {
if !should_run_port_mapping_in_dedicated_thread(&global_ctx) {
return ActiveUdpPortMapping::discover_nat_pmp_gateway(&local_listener).await;
}
tokio::task::spawn_blocking(move || {
let _g = global_ctx.net_ns.guard();
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("build runtime for nat-pmp gateway discovery")?
.block_on(ActiveUdpPortMapping::discover_nat_pmp_gateway(
&local_listener,
))
})
.await
.context("join nat-pmp gateway discovery task")?
}
async fn establish_nat_pmp_mapping_in_netns(
global_ctx: ArcGlobalCtx,
local_listener: url::Url,
gateway: Ipv4Addr,
local_addr: SocketAddr,
) -> anyhow::Result<ActiveUdpPortMapping> {
if !should_run_port_mapping_in_dedicated_thread(&global_ctx) {
return ActiveUdpPortMapping::establish_via_nat_pmp(&local_listener, gateway, local_addr)
.await;
}
tokio::task::spawn_blocking(move || {
let _g = global_ctx.net_ns.guard();
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("build runtime for nat-pmp mapping establishment")?
.block_on(ActiveUdpPortMapping::establish_via_nat_pmp(
&local_listener,
gateway,
local_addr,
))
})
.await
.context("join nat-pmp mapping establishment task")?
}
async fn run_udp_port_mapping_task(
local_listener: url::Url,
mapping: ActiveUdpPortMapping,
mut stop_rx: oneshot::Receiver<()>,
) {
loop {
tokio::select! {
_ = tokio::time::sleep(UPNP_RENEW_INTERVAL) => {
if let Err(err) = mapping.renew().await {
tracing::warn!(
?err,
%local_listener,
backend = mapping.backend_name(),
gateway_external_port = mapping.gateway_external_port,
"failed to renew udp port mapping"
);
}
}
_ = &mut stop_rx => break,
}
}
if let Err(err) = mapping.remove().await {
tracing::debug!(
?err,
%local_listener,
backend = mapping.backend_name(),
gateway_external_port = mapping.gateway_external_port,
"failed to remove udp port mapping"
);
}
}
fn should_run_port_mapping_in_dedicated_thread(global_ctx: &ArcGlobalCtx) -> bool {
global_ctx.net_ns.name().is_some()
}
async fn add_udp_mapping_port_igd(
gateway: &TokioGateway,
local_addr: SocketAddr,
local_listener: &url::Url,
) -> anyhow::Result<u16> {
match gateway
.add_any_port(
PortMappingProtocol::UDP,
local_addr,
UPNP_LEASE_DURATION_SECS,
UPNP_DESCRIPTION,
)
.await
{
Ok(external_port) => Ok(external_port),
Err(AddAnyPortError::RequestError(err)) => {
tracing::debug!(
?err,
%local_listener,
gateway = %gateway.addr,
%local_addr,
"igd any-port udp mapping failed, retry with same-port mapping"
);
gateway
.add_port(
PortMappingProtocol::UDP,
local_addr.port(),
local_addr,
UPNP_LEASE_DURATION_SECS,
UPNP_DESCRIPTION,
)
.await
.map(|_| local_addr.port())
.map_err(|same_port_err| {
anyhow!(
"igd udp mapping failed for {local_listener}: any-port error: {err}; same-port error: {same_port_err}"
)
})
}
Err(err) => Err(err.into()),
}
}
async fn add_udp_mapping_port_nat_pmp(
gateway: Ipv4Addr,
local_addr: SocketAddr,
local_listener: &url::Url,
) -> anyhow::Result<u16> {
match request_nat_pmp_mapping(gateway, local_addr.port(), 0, UPNP_LEASE_DURATION_SECS).await {
Ok(external_port) => Ok(external_port),
Err(any_port_err) => {
tracing::debug!(
?any_port_err,
%local_listener,
gateway = %gateway,
%local_addr,
"nat-pmp any-port udp mapping failed, retry with same-port mapping"
);
request_nat_pmp_mapping(
gateway,
local_addr.port(),
local_addr.port(),
UPNP_LEASE_DURATION_SECS,
)
.await
.map_err(|same_port_err| {
anyhow!(
"nat-pmp udp mapping failed for {local_listener}: any-port error: {any_port_err}; same-port error: {same_port_err}"
)
})
}
}
}
async fn request_nat_pmp_mapping(
gateway: Ipv4Addr,
private_port: u16,
public_port: u16,
lifetime_secs: u32,
) -> anyhow::Result<u16> {
let client = new_tokio_natpmp_with(gateway)
.await
.with_context(|| format!("create nat-pmp client for gateway {gateway}"))?;
client
.send_port_mapping_request(
NatPmpProtocol::UDP,
private_port,
public_port,
lifetime_secs,
)
.await
.with_context(|| {
format!(
"send nat-pmp udp mapping request private_port={private_port} public_port={public_port} gateway={gateway}"
)
})?;
let response = tokio::time::timeout(NAT_PMP_RESPONSE_TIMEOUT, client.read_response_or_retry())
.await
.with_context(|| {
format!(
"wait nat-pmp udp mapping response private_port={private_port} gateway={gateway}"
)
})?
.map_err(anyhow::Error::from)
.with_context(|| {
format!(
"read nat-pmp udp mapping response private_port={private_port} gateway={gateway}"
)
})?;
match response {
NatPmpResponse::UDP(mapping) | NatPmpResponse::TCP(mapping) => Ok(mapping.public_port()),
NatPmpResponse::Gateway(_) => {
bail!("unexpected nat-pmp gateway response for udp mapping request")
}
}
}
async fn renew_udp_mapping_nat_pmp(
gateway: Ipv4Addr,
local_addr: SocketAddr,
external_port: u16,
local_listener: &url::Url,
) -> anyhow::Result<()> {
request_nat_pmp_mapping(
gateway,
local_addr.port(),
external_port,
UPNP_LEASE_DURATION_SECS,
)
.await
.map(|_| ())
.with_context(|| format!("renew udp port mapping {local_listener}"))
}
async fn remove_udp_mapping_nat_pmp(
gateway: Ipv4Addr,
local_addr: SocketAddr,
external_port: u16,
local_listener: &url::Url,
) -> anyhow::Result<()> {
request_nat_pmp_mapping(gateway, local_addr.port(), external_port, 0)
.await
.map(|_| ())
.with_context(|| format!("remove udp port mapping {local_listener}"))
}
fn should_map_udp_listener(local_listener: &url::Url) -> bool {
if local_listener.scheme() != "udp" {
return false;
}
let Some(host) = listener_ipv4_host(local_listener) else {
return false;
};
if host.is_loopback() || host.is_broadcast() {
return false;
}
host.is_unspecified() || host.is_private() || host.is_link_local()
}
fn listener_ipv4_host(local_listener: &url::Url) -> Option<Ipv4Addr> {
local_listener.host_str()?.parse().ok()
}
async fn resolve_internal_addr(
gateway_addr: SocketAddr,
local_listener: &url::Url,
) -> anyhow::Result<SocketAddr> {
let port = local_listener
.port()
.ok_or_else(|| anyhow!("listener port is missing"))?;
let host =
listener_ipv4_host(local_listener).ok_or_else(|| anyhow!("listener must be ipv4"))?;
let ip = if host.is_unspecified() {
let udp = std::net::UdpSocket::bind("0.0.0.0:0")
.context("bind probe socket for gateway route")?;
udp.connect(gateway_addr)
.with_context(|| format!("connect probe socket to gateway {gateway_addr}"))?;
let SocketAddr::V4(local_addr) = udp.local_addr().context("get probe socket local addr")?
else {
bail!("gateway route selected a non-ipv4 local address");
};
*local_addr.ip()
} else {
host
};
Ok(SocketAddr::new(ip.into(), port))
}
async fn renew_udp_mapping_igd(
gateway: &TokioGateway,
local_addr: SocketAddr,
external_port: u16,
local_listener: &url::Url,
) -> anyhow::Result<()> {
gateway
.add_port(
PortMappingProtocol::UDP,
external_port,
local_addr,
UPNP_LEASE_DURATION_SECS,
UPNP_DESCRIPTION,
)
.await
.with_context(|| format!("renew udp port mapping {local_listener}"))
}
async fn remove_udp_mapping_igd(
gateway: &TokioGateway,
external_port: u16,
local_listener: &url::Url,
) -> anyhow::Result<()> {
gateway
.remove_port(PortMappingProtocol::UDP, external_port)
.await
.with_context(|| format!("remove udp port mapping {local_listener}"))
}
#[cfg(test)]
mod tests {
#[test]
fn udp_mapping_requires_private_or_unspecified_ipv4_listener() {
assert!(super::should_map_udp_listener(
&"udp://0.0.0.0:11010".parse().unwrap()
));
assert!(super::should_map_udp_listener(
&"udp://192.168.1.10:11010".parse().unwrap()
));
assert!(!super::should_map_udp_listener(
&"udp://127.0.0.1:11010".parse().unwrap()
));
assert!(!super::should_map_udp_listener(
&"udp://8.8.8.8:11010".parse().unwrap()
));
assert!(!super::should_map_udp_listener(
&"tcp://0.0.0.0:11010".parse().unwrap()
));
}
}
+251 -55
View File
@@ -2,7 +2,7 @@
use std::{
collections::HashSet,
net::{IpAddr, Ipv6Addr, SocketAddr},
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
str::FromStr,
sync::{
Arc,
@@ -12,10 +12,7 @@ use std::{
};
use crate::{
common::{
PeerId, dns::socket_addrs, error::Error, global_ctx::ArcGlobalCtx,
stun::StunInfoCollectorTrait,
},
common::{PeerId, error::Error, global_ctx::ArcGlobalCtx, stun::StunInfoCollectorTrait},
connector::udp_hole_punch::handle_rpc_result,
peers::{
peer_conn::PeerConnId,
@@ -27,7 +24,7 @@ use crate::{
proto::{
peer_rpc::{
DirectConnectorRpc, DirectConnectorRpcClientFactory, DirectConnectorRpcServer,
GetIpListRequest, GetIpListResponse, SendV6HolePunchPacketRequest,
GetIpListRequest, GetIpListResponse, SendUdpHolePunchPacketRequest,
},
rpc_types::controller::BaseController,
},
@@ -40,6 +37,7 @@ use super::{
udp_hole_punch,
};
use crate::tunnel::{FromUrl, IpScheme, TunnelScheme, matches_scheme};
use crate::utils::dns::socket_addrs;
use anyhow::Context;
use rand::Rng;
use socket2::Protocol;
@@ -51,6 +49,37 @@ pub const DIRECT_CONNECTOR_BLACKLIST_TIMEOUT_SEC: u64 = 300;
static TESTING: AtomicBool = AtomicBool::new(false);
fn mapped_listener_port(url: &url::Url) -> Option<u16> {
url.port().or_else(|| {
TunnelScheme::try_from(url)
.ok()
.and_then(|scheme| IpScheme::try_from(scheme).ok())
.map(IpScheme::default_port)
})
}
async fn resolve_mapped_listener_addrs(listener: &url::Url) -> Result<Vec<SocketAddr>, Error> {
socket_addrs(listener, || mapped_listener_port(listener)).await
}
fn is_usable_public_ipv6_candidate(ip: &Ipv6Addr, global_ctx: &ArcGlobalCtx) -> bool {
is_usable_public_ipv6_candidate_with_mode(ip, global_ctx, TESTING.load(Ordering::Relaxed))
}
fn is_usable_public_ipv6_candidate_with_mode(
ip: &Ipv6Addr,
global_ctx: &ArcGlobalCtx,
testing: bool,
) -> bool {
!global_ctx.is_ip_easytier_managed_ipv6(ip)
&& (testing
|| (!ip.is_loopback()
&& !ip.is_unspecified()
&& !ip.is_unique_local()
&& !ip.is_unicast_link_local()
&& !ip.is_multicast()))
}
#[async_trait::async_trait]
pub trait PeerManagerForDirectConnector {
async fn list_peers(&self) -> Vec<PeerId>;
@@ -117,37 +146,25 @@ impl DirectConnectorManagerData {
}
}
async fn remote_send_v6_hole_punch_packet(
async fn remote_send_udp_hole_punch_packet(
&self,
dst_peer_id: PeerId,
local_socket: &UdpSocket,
connector_addr: SocketAddr,
remote_url: &url::Url,
) -> Result<(), Error> {
if !matches_scheme!(remote_url, TunnelScheme::Ip(IpScheme::Udp)) {
return Err(anyhow::anyhow!(
"udp hole punch packet only applies to udp listener: {}",
remote_url
)
.into());
}
let global_ctx = self.peer_manager.get_global_ctx();
let listener_port = remote_url.port().ok_or(anyhow::anyhow!(
let listener_port = mapped_listener_port(remote_url).ok_or(anyhow::anyhow!(
"failed to parse port from remote url: {}",
remote_url
))?;
let connector_ip = global_ctx
.get_stun_info_collector()
.get_stun_info()
.public_ip
.iter()
.find(|x| x.contains(":"))
.ok_or(anyhow::anyhow!(
"failed to get public ipv6 address from stun info"
))?
.parse::<std::net::Ipv6Addr>()
.with_context(|| {
format!(
"failed to parse public ipv6 address from stun info: {:?}",
global_ctx.get_stun_info_collector().get_stun_info()
)
})?;
let connector_addr = SocketAddr::new(
std::net::IpAddr::V6(connector_ip),
local_socket.local_addr()?.port(),
);
let rpc_stub = self
.peer_manager
@@ -160,9 +177,9 @@ impl DirectConnectorManagerData {
);
rpc_stub
.send_v6_hole_punch_packet(
.send_udp_hole_punch_packet(
BaseController::default(),
SendV6HolePunchPacketRequest {
SendUdpHolePunchPacketRequest {
listener_port: listener_port as u32,
connector_addr: Some(connector_addr.into()),
},
@@ -170,7 +187,7 @@ impl DirectConnectorManagerData {
.await
.with_context(|| {
format!(
"do rpc, send v6 hole punch packet to peer {} at {}",
"do rpc, send udp hole punch packet to peer {} at {}",
dst_peer_id, remote_url
)
})?;
@@ -188,12 +205,29 @@ impl DirectConnectorManagerData {
.await
.with_context(|| format!("failed to bind local socket for {}", remote_url))?,
);
let connector_ip = self
.global_ctx
.get_stun_info_collector()
.get_stun_info()
.public_ip
.iter()
.filter_map(|ip| ip.parse::<Ipv6Addr>().ok())
.find(|ip| !self.global_ctx.is_ip_easytier_managed_ipv6(ip));
// ask remote to send v6 hole punch packet
// and no matter what the result is, continue to connect
let _ = self
.remote_send_v6_hole_punch_packet(dst_peer_id, &local_socket, remote_url)
.await;
if let Some(connector_ip) = connector_ip {
let connector_addr =
SocketAddr::new(IpAddr::V6(connector_ip), local_socket.local_addr()?.port());
let _ = self
.remote_send_udp_hole_punch_packet(dst_peer_id, connector_addr, remote_url)
.await;
} else {
tracing::debug!(
?remote_url,
"skip remote IPv6 hole-punch packet; no non-EasyTier public IPv6 in STUN info"
);
}
let udp_connector = UdpTunnelConnector::new(remote_url.clone());
let remote_addr = SocketAddr::from_url(remote_url.clone(), IpVersion::V6).await?;
@@ -207,14 +241,80 @@ impl DirectConnectorManagerData {
.await
}
async fn connect_to_public_ipv4(
&self,
dst_peer_id: PeerId,
remote_url: &url::Url,
) -> Result<(PeerId, PeerConnId), Error> {
let local_socket = {
let _g = self.global_ctx.net_ns.guard();
Arc::new(
UdpSocket::bind("0.0.0.0:0")
.await
.with_context(|| format!("failed to bind local socket for {}", remote_url))?,
)
};
let connector_addr = self
.peer_manager
.get_global_ctx()
.get_stun_info_collector()
.get_udp_port_mapping_with_socket(local_socket.clone())
.await
.with_context(|| format!("failed to get udp port mapping for {}", remote_url))?;
let _ = self
.remote_send_udp_hole_punch_packet(dst_peer_id, connector_addr, remote_url)
.await;
let udp_connector = UdpTunnelConnector::new(remote_url.clone());
let remote_addr = SocketAddr::from_url(remote_url.clone(), IpVersion::V4).await?;
let ret = udp_connector
.try_connect_with_socket(local_socket, remote_addr)
.await?;
self.peer_manager
.add_client_tunnel_with_peer_id_hint(ret, true, Some(dst_peer_id))
.await
}
async fn do_try_connect_to_ip(&self, dst_peer_id: PeerId, addr: String) -> Result<(), Error> {
let connector = create_connector_by_url(&addr, &self.global_ctx, IpVersion::Both).await?;
let remote_url = connector.remote_url();
let (peer_id, conn_id) = if matches_scheme!(remote_url, TunnelScheme::Ip(IpScheme::Udp))
&& matches!(remote_url.host(), Some(Host::Ipv6(_)))
{
self.connect_to_public_ipv6(dst_peer_id, &remote_url)
.await?
let (peer_id, conn_id) = if matches_scheme!(remote_url, TunnelScheme::Ip(IpScheme::Udp)) {
match remote_url.host() {
Some(Host::Ipv6(_)) => {
self.connect_to_public_ipv6(dst_peer_id, &remote_url)
.await?
}
Some(Host::Ipv4(ip)) if is_public_ipv4(ip) => {
match self.connect_to_public_ipv4(dst_peer_id, &remote_url).await {
Ok(ret) => ret,
Err(err) => {
tracing::debug!(
?err,
%remote_url,
"udp public ipv4 listener punch failed, falling back to direct connect"
);
timeout(
std::time::Duration::from_secs(3),
self.peer_manager.try_direct_connect_with_peer_id_hint(
connector,
Some(dst_peer_id),
),
)
.await??
}
}
}
_ => {
timeout(
std::time::Duration::from_secs(3),
self.peer_manager
.try_direct_connect_with_peer_id_hint(connector, Some(dst_peer_id)),
)
.await??
}
}
} else {
timeout(
std::time::Duration::from_secs(3),
@@ -305,7 +405,7 @@ impl DirectConnectorManagerData {
listener: &url::Url,
tasks: &mut JoinSet<Result<(), Error>>,
) {
let Ok(mut addrs) = socket_addrs(listener, || None).await else {
let Ok(mut addrs) = resolve_mapped_listener_addrs(listener).await else {
tracing::error!(?listener, "failed to parse socket address from listener");
return;
};
@@ -389,14 +489,7 @@ impl DirectConnectorManagerData {
.iter()
.chain(ip_list.public_ipv6.iter())
.filter_map(|x| Ipv6Addr::from_str(&x.to_string()).ok())
.filter(|x| {
TESTING.load(Ordering::Relaxed)
|| (!x.is_loopback()
&& !x.is_unspecified()
&& !x.is_unique_local()
&& !x.is_unicast_link_local()
&& !x.is_multicast())
})
.filter(|x| is_usable_public_ipv6_candidate(x, &self.global_ctx))
.collect::<HashSet<_>>()
.iter()
.for_each(|ip| {
@@ -425,6 +518,11 @@ impl DirectConnectorManagerData {
);
}
});
} else if self.global_ctx.is_ip_easytier_managed_ipv6(s_addr.ip()) {
tracing::debug!(
?listener,
"skip EasyTier-managed IPv6 as direct-connect target"
);
} else if !s_addr.ip().is_loopback() || TESTING.load(Ordering::Relaxed) {
if self
.global_ctx
@@ -459,7 +557,7 @@ impl DirectConnectorManagerData {
.into_iter()
.map(Into::<url::Url>::into)
.filter_map(|l| if l.scheme() != "ring" { Some(l) } else { None })
.filter(|l| l.port().is_some() && l.host().is_some())
.filter(|l| mapped_listener_port(l).is_some() && l.host().is_some())
.filter(|l| enable_ipv6 || !matches!(l.host().unwrap().to_owned(), Host::Ipv6(_)))
.collect::<Vec<_>>();
@@ -577,6 +675,14 @@ impl DirectConnectorManagerData {
}
}
fn is_public_ipv4(ip: Ipv4Addr) -> bool {
!ip.is_private()
&& !ip.is_loopback()
&& !ip.is_link_local()
&& !ip.is_broadcast()
&& !ip.is_unspecified()
}
impl std::fmt::Debug for DirectConnectorManagerData {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DirectConnectorManagerData")
@@ -677,13 +783,25 @@ impl DirectConnectorManager {
pub fn run_as_client(&mut self) {
self.client.start();
}
#[cfg(test)]
pub(crate) async fn try_direct_connect_with_ip_list(
&self,
dst_peer_id: PeerId,
ip_list: GetIpListResponse,
) -> Result<(), Error> {
self.data
.do_try_direct_connect_internal(dst_peer_id, ip_list)
.await
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::{collections::BTreeSet, sync::Arc};
use crate::{
common::global_ctx::tests::get_mock_global_ctx,
connector::direct::{
DirectConnectorManager, DirectConnectorManagerData, DstListenerUrlBlackListItem,
},
@@ -693,12 +811,84 @@ mod tests {
wait_route_appear_with_cost,
},
proto::peer_rpc::GetIpListResponse,
tunnel::{IpScheme, TunnelScheme, matches_scheme},
};
use super::TESTING;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use super::{TESTING, mapped_listener_port, resolve_mapped_listener_addrs};
#[tokio::test]
async fn direct_connector_mapped_listener() {
async fn public_ipv6_candidate_rejects_easytier_managed_addr_even_in_tests() {
let global_ctx = get_mock_global_ctx();
let managed_ipv6: cidr::Ipv6Inet = "2001:db8::2/128".parse().unwrap();
global_ctx.set_public_ipv6_routes(BTreeSet::from([managed_ipv6]));
assert!(!super::is_usable_public_ipv6_candidate_with_mode(
&"2001:db8::2".parse().unwrap(),
&global_ctx,
true,
));
assert!(super::is_usable_public_ipv6_candidate_with_mode(
&"::1".parse().unwrap(),
&global_ctx,
true,
));
}
#[test]
fn udp_ipv6_url_matches_hole_punch_branch_condition() {
let remote_url: url::Url = "udp://[2001:db8::1]:11010".parse().unwrap();
let takes_udp_ipv6_hole_punch_branch =
matches_scheme!(remote_url, TunnelScheme::Ip(IpScheme::Udp))
&& matches!(remote_url.host(), Some(url::Host::Ipv6(_)));
assert!(takes_udp_ipv6_hole_punch_branch);
}
#[test]
fn mapped_listener_port_uses_ip_scheme_defaults() {
assert_eq!(
mapped_listener_port(&"ws://example.com".parse().unwrap()),
Some(80)
);
assert_eq!(
mapped_listener_port(&"wss://example.com".parse().unwrap()),
Some(443)
);
assert_eq!(
mapped_listener_port(&"tcp://127.0.0.1".parse().unwrap()),
Some(11010)
);
assert_eq!(
mapped_listener_port(&"udp://127.0.0.1".parse().unwrap()),
Some(11010)
);
}
#[tokio::test]
async fn resolve_mapped_listener_addrs_uses_default_ports() {
let wss_addrs = resolve_mapped_listener_addrs(&"wss://127.0.0.1".parse().unwrap())
.await
.unwrap();
assert_eq!(
wss_addrs,
vec![SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 443)]
);
let tcp_addrs = resolve_mapped_listener_addrs(&"tcp://127.0.0.1".parse().unwrap())
.await
.unwrap();
assert_eq!(
tcp_addrs,
vec![SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 11010)]
);
}
async fn run_direct_connector_mapped_listener_test(
mapped_listener: &str,
target_listener: &str,
) {
TESTING.store(true, std::sync::atomic::Ordering::Relaxed);
let p_a = create_mock_peer_manager().await;
let p_b = create_mock_peer_manager().await;
@@ -717,11 +907,11 @@ mod tests {
p_c.get_global_ctx()
.config
.set_mapped_listeners(Some(vec!["tcp://127.0.0.1:11334".parse().unwrap()]));
.set_mapped_listeners(Some(vec![mapped_listener.parse().unwrap()]));
p_x.get_global_ctx()
.config
.set_listeners(vec!["tcp://0.0.0.0:11334".parse().unwrap()]);
.set_listeners(vec![target_listener.parse().unwrap()]);
let mut lis_x = ListenerManager::new(p_x.get_global_ctx(), p_x.clone());
lis_x.prepare_listeners().await.unwrap();
lis_x.run().await.unwrap();
@@ -738,6 +928,12 @@ mod tests {
.unwrap();
}
#[tokio::test]
async fn direct_connector_mapped_listener() {
run_direct_connector_mapped_listener_test("tcp://127.0.0.1:11334", "tcp://0.0.0.0:11334")
.await;
}
#[rstest::rstest]
#[tokio::test]
async fn direct_connector_basic_test(
+14 -27
View File
@@ -1,19 +1,15 @@
use std::{net::SocketAddr, sync::Arc};
use super::{create_connector_by_url, http_connector::TunnelWithInfo};
use crate::utils::dns::{srv_lookup, txt_resolve};
use crate::{
common::{
dns::{RESOLVER, resolve_txt_record},
error::Error,
global_ctx::ArcGlobalCtx,
log,
},
common::{error::Error, global_ctx::ArcGlobalCtx, log},
proto::common::TunnelInfo,
tunnel::{IpScheme, IpVersion, Tunnel, TunnelConnector, TunnelError, TunnelScheme},
};
use anyhow::Context;
use dashmap::DashSet;
use hickory_resolver::proto::rr::rdata::SRV;
use hickory_proto::rr::rdata::SRV;
use rand::{Rng as _, seq::SliceRandom};
use strum::VariantArray;
@@ -58,14 +54,13 @@ impl DnsTunnelConnector {
&self,
domain_name: &str,
) -> Result<Box<dyn TunnelConnector>, Error> {
let txt_data = resolve_txt_record(domain_name)
let txt_data = txt_resolve(domain_name)
.await
.with_context(|| format!("resolve txt record failed, domain_name: {}", domain_name))?;
let candidate_urls = txt_data
.split(" ")
.map(|s| s.to_string())
.filter_map(|s| url::Url::parse(s.as_str()).ok())
.iter()
.filter_map(|s| url::Url::parse(s).ok())
.collect::<Vec<_>>();
// shuffle candidate_urls and get the first one
@@ -73,7 +68,7 @@ impl DnsTunnelConnector {
.choose(&mut rand::thread_rng())
.with_context(|| {
format!(
"no valid url found, txt_data: {}, expecting an url list splitted by space",
"no valid url found, txt_data: {:?}, expecting an url list split by space",
txt_data
)
})?;
@@ -83,26 +78,23 @@ impl DnsTunnelConnector {
Ok(connector)
}
fn handle_one_srv_record(record: &SRV, protocol: IpScheme) -> Result<(url::Url, u64), Error> {
fn handle_one_srv_record(record: SRV, protocol: IpScheme) -> Result<(url::Url, u64), Error> {
// port must be non-zero
if record.port() == 0 {
if record.port == 0 {
return Err(anyhow::anyhow!("port must be non-zero").into());
}
let connector_dst = record.target().to_utf8();
let dst_url = format!("{}://{}:{}", protocol, connector_dst, record.port());
let connector_dst = record.target.to_utf8();
let dst_url = format!("{}://{}:{}", protocol, connector_dst, record.port);
Ok((
dst_url.parse().with_context(|| {
format!(
"parse dst_url failed, protocol: {}, connector_dst: {}, port: {}, dst_url: {}",
protocol,
connector_dst,
record.port(),
dst_url
protocol, connector_dst, record.port, dst_url
)
})?,
record.priority() as _,
record.priority as _,
))
}
@@ -122,14 +114,9 @@ impl DnsTunnelConnector {
let srv_lookup_tasks = srv_domains
.iter()
.map(|(protocol, srv_domain)| {
let resolver = RESOLVER.clone();
let responses = responses.clone();
async move {
let response = resolver.srv_lookup(srv_domain).await.with_context(|| {
format!("srv_lookup failed, srv_domain: {}", srv_domain)
})?;
tracing::info!(?response, ?srv_domain, "srv_lookup response");
for record in response.iter() {
for record in srv_lookup(srv_domain).await? {
let parsed_record = Self::handle_one_srv_record(record, **protocol);
tracing::info!(?parsed_record, ?srv_domain, "parsed_record");
if let Err(e) = &parsed_record {
+165 -55
View File
@@ -1,13 +1,15 @@
use std::{
collections::BTreeSet,
future::Future,
sync::{Arc, Weak},
time::{Duration, Instant},
};
use dashmap::DashSet;
use tokio::{sync::mpsc, task::JoinSet, time::timeout};
use crate::{
common::{PeerId, dns::socket_addrs, join_joinset_background},
common::{PeerId, join_joinset_background},
peers::peer_conn::PeerConnId,
proto::{
api::instance::{
@@ -16,10 +18,12 @@ use crate::{
},
rpc_types::{self, controller::BaseController},
},
tunnel::{IpVersion, TunnelConnector},
tunnel::{IpVersion, TunnelConnector, TunnelScheme, matches_scheme},
utils::weak_upgrade,
};
use super::create_connector_by_url;
use crate::utils::dns::socket_addrs;
use crate::{
common::{
error::Error,
@@ -30,8 +34,6 @@ use crate::{
use_global_var,
};
use super::create_connector_by_url;
type ConnectorMap = Arc<DashSet<url::Url>>;
#[derive(Debug, Clone)]
@@ -83,6 +85,55 @@ impl ManualConnectorManager {
ret
}
fn reconnect_timeout(dead_url: &url::Url) -> Duration {
let use_long_timeout = matches_scheme!(
dead_url,
TunnelScheme::Http | TunnelScheme::Https | TunnelScheme::Txt | TunnelScheme::Srv
) || matches!(dead_url.scheme(), "ws" | "wss");
Duration::from_secs(if use_long_timeout { 20 } else { 2 })
}
fn remaining_budget(started_at: Instant, total_timeout: Duration) -> Option<Duration> {
let remaining = total_timeout.checked_sub(started_at.elapsed())?;
(!remaining.is_zero()).then_some(remaining)
}
fn emit_connect_error(
data: &ConnectorManagerData,
dead_url: &url::Url,
ip_version: IpVersion,
error: &Error,
) {
data.global_ctx.issue_event(GlobalCtxEvent::ConnectError(
dead_url.to_string(),
format!("{:?}", ip_version),
format!("{:#?}", error),
));
}
fn reconnect_timeout_error(stage: &str, duration: Duration) -> Error {
Error::AnyhowError(anyhow::anyhow!("{} timeout after {:?}", stage, duration))
}
async fn with_reconnect_timeout<T, F>(
stage: &'static str,
started_at: Instant,
total_timeout: Duration,
fut: F,
) -> Result<T, Error>
where
F: Future<Output = Result<T, Error>>,
{
let remaining = Self::remaining_budget(started_at, total_timeout)
.ok_or_else(|| Self::reconnect_timeout_error(stage, started_at.elapsed()))?;
timeout(remaining, fut)
.await
.map_err(|_| Self::reconnect_timeout_error(stage, remaining))?
}
}
impl ManualConnectorManager {
pub fn add_connector<T>(&self, connector: T)
where
T: TunnelConnector + 'static,
@@ -242,11 +293,18 @@ impl ManualConnectorManager {
async fn conn_reconnect_with_ip_version(
data: Arc<ConnectorManagerData>,
dead_url: String,
dead_url: url::Url,
ip_version: IpVersion,
started_at: Instant,
total_timeout: Duration,
) -> Result<ReconnResult, Error> {
let connector =
create_connector_by_url(&dead_url, &data.global_ctx.clone(), ip_version).await?;
let connector = Self::with_reconnect_timeout(
"resolve",
started_at,
total_timeout,
create_connector_by_url(dead_url.as_str(), &data.global_ctx, ip_version),
)
.await?;
data.global_ctx
.issue_event(GlobalCtxEvent::Connecting(connector.remote_url()));
@@ -257,10 +315,25 @@ impl ManualConnectorManager {
)));
};
let (peer_id, conn_id) = pm.try_direct_connect(connector).await?;
let tunnel = Self::with_reconnect_timeout(
"connect",
started_at,
total_timeout,
pm.connect_tunnel(connector),
)
.await?;
let (peer_id, conn_id) = Self::with_reconnect_timeout(
"handshake",
started_at,
total_timeout,
pm.add_client_tunnel_with_peer_id_hint(tunnel, true, None),
)
.await?;
tracing::info!("reconnect succ: {} {} {}", peer_id, conn_id, dead_url);
Ok(ReconnResult {
dead_url,
dead_url: dead_url.to_string(),
peer_id,
conn_id,
})
@@ -273,22 +346,33 @@ impl ManualConnectorManager {
tracing::info!("reconnect: {}", dead_url);
let mut ip_versions = vec![];
if dead_url.scheme() == "ring" || dead_url.scheme() == "txt" || dead_url.scheme() == "srv" {
if matches_scheme!(
dead_url,
TunnelScheme::Ring | TunnelScheme::Txt | TunnelScheme::Srv
) {
ip_versions.push(IpVersion::Both);
} else {
let converted_dead_url = crate::common::idn::convert_idn_to_ascii(dead_url.clone())?;
let addrs = match socket_addrs(&converted_dead_url, || Some(1000)).await {
let converted_dead_url =
match crate::common::idn::convert_idn_to_ascii(dead_url.clone()) {
Ok(url) => url,
Err(error) => {
let error: Error = error.into();
Self::emit_connect_error(&data, &dead_url, IpVersion::Both, &error);
return Err(error);
}
};
let addrs = match Self::with_reconnect_timeout(
"resolve",
Instant::now(),
Self::reconnect_timeout(&dead_url),
socket_addrs(&converted_dead_url, || Some(1000)),
)
.await
{
Ok(addrs) => addrs,
Err(e) => {
data.global_ctx.issue_event(GlobalCtxEvent::ConnectError(
dead_url.to_string(),
format!("{:?}", IpVersion::Both),
format!("{:?}", e),
));
return Err(Error::AnyhowError(anyhow::anyhow!(
"get ip from url failed: {:?}",
e
)));
Err(error) => {
Self::emit_connect_error(&data, &dead_url, IpVersion::Both, &error);
return Err(error);
}
};
tracing::info!(?addrs, ?dead_url, "get ip from url done");
@@ -313,46 +397,24 @@ impl ManualConnectorManager {
"cannot get ip from url"
)));
for ip_version in ip_versions {
let use_long_timeout = dead_url.scheme() == "http"
|| dead_url.scheme() == "https"
|| dead_url.scheme() == "ws"
|| dead_url.scheme() == "wss"
|| dead_url.scheme() == "txt"
|| dead_url.scheme() == "srv";
let ret = timeout(
// allow http/websocket connector to wait longer
std::time::Duration::from_secs(if use_long_timeout { 20 } else { 2 }),
Self::conn_reconnect_with_ip_version(
data.clone(),
dead_url.to_string(),
ip_version,
),
let started_at = Instant::now();
let ret = Self::conn_reconnect_with_ip_version(
data.clone(),
dead_url.clone(),
ip_version,
started_at,
Self::reconnect_timeout(&dead_url),
)
.await;
tracing::info!("reconnect: {} done, ret: {:?}", dead_url, ret);
match ret {
Ok(Ok(_)) => {
// 外层和内层都成功:解包并跳出
reconn_ret = ret.unwrap();
break;
}
Ok(Err(e)) => {
// 外层成功,内层失败
reconn_ret = Err(e);
}
Err(e) => {
// 外层失败
reconn_ret = Err(e.into());
Ok(result) => return Ok(result),
Err(error) => {
Self::emit_connect_error(&data, &dead_url, ip_version, &error);
reconn_ret = Err(error);
}
}
// 发送事件(只有在未 break 时才执行)
data.global_ctx.issue_event(GlobalCtxEvent::ConnectError(
dead_url.to_string(),
format!("{:?}", ip_version),
format!("{:?}", reconn_ret),
));
}
reconn_ret
@@ -388,6 +450,54 @@ mod tests {
use super::*;
#[tokio::test]
async fn reconnect_timeout_reports_exhausted_budget_for_stage() {
let started_at = Instant::now() - Duration::from_millis(50);
let err = ManualConnectorManager::with_reconnect_timeout(
"resolve",
started_at,
Duration::from_millis(1),
async { Ok::<(), Error>(()) },
)
.await
.unwrap_err();
let message = err.to_string();
assert!(message.contains("resolve timeout after"));
}
#[tokio::test]
async fn reconnect_timeout_reports_stage_timeout_with_remaining_budget() {
let err = ManualConnectorManager::with_reconnect_timeout(
"handshake",
Instant::now(),
Duration::from_millis(10),
async {
tokio::time::sleep(Duration::from_millis(50)).await;
Ok::<(), Error>(())
},
)
.await
.unwrap_err();
let message = err.to_string();
assert!(message.contains("handshake timeout after"));
}
#[tokio::test]
async fn reconnect_timeout_preserves_success_within_budget() {
let result = ManualConnectorManager::with_reconnect_timeout(
"connect",
Instant::now(),
Duration::from_millis(50),
async { Ok::<_, Error>(123_u32) },
)
.await
.unwrap();
assert_eq!(result, 123);
}
#[tokio::test]
async fn test_reconnect_with_connecting_addr() {
set_global_var!(MANUAL_CONNECTOR_RECONNECT_INTERVAL_MS, 1);
+181 -16
View File
@@ -1,19 +1,17 @@
use std::{
net::{SocketAddr, SocketAddrV4, SocketAddrV6},
sync::Arc,
};
use std::net::{IpAddr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6};
use crate::{
common::{error::Error, global_ctx::ArcGlobalCtx, idn, network::IPCollector},
common::{error::Error, global_ctx::ArcGlobalCtx, idn},
connector::dns_connector::DnsTunnelConnector,
proto::common::PeerFeatureFlag,
tunnel::{
self, FromUrl, IpScheme, IpVersion, TunnelConnector, TunnelError, TunnelScheme,
self, IpScheme, IpVersion, TunnelConnector, TunnelError, TunnelScheme,
ring::RingTunnelConnector, tcp::TcpTunnelConnector, udp::UdpTunnelConnector,
},
utils::BoxExt,
utils::{BoxExt, dns::socket_addrs},
};
use http_connector::HttpTunnelConnector;
use rand::seq::SliceRandom;
pub mod direct;
pub mod manual;
@@ -56,7 +54,7 @@ pub(crate) fn should_background_p2p_with_peer(
async fn set_bind_addr_for_peer_connector(
connector: &mut (impl TunnelConnector + ?Sized),
is_ipv4: bool,
ip_collector: &Arc<IPCollector>,
global_ctx: &ArcGlobalCtx,
) {
if cfg!(any(
target_os = "android",
@@ -69,7 +67,7 @@ async fn set_bind_addr_for_peer_connector(
return;
}
let ips = ip_collector.collect_ip_addrs().await;
let ips = global_ctx.get_ip_collector().collect_ip_addrs().await;
if is_ipv4 {
let mut bind_addrs = vec![];
for ipv4 in ips.interface_ipv4s {
@@ -80,7 +78,11 @@ async fn set_bind_addr_for_peer_connector(
} else {
let mut bind_addrs = vec![];
for ipv6 in ips.interface_ipv6s.iter().chain(ips.public_ipv6.iter()) {
let socket_addr = SocketAddrV6::new(std::net::Ipv6Addr::from(*ipv6), 0, 0, 0).into();
let ipv6 = std::net::Ipv6Addr::from(*ipv6);
if global_ctx.is_ip_easytier_managed_ipv6(&ipv6) {
continue;
}
let socket_addr = SocketAddrV6::new(ipv6, 0, 0, 0).into();
bind_addrs.push(socket_addr);
}
connector.set_bind_addrs(bind_addrs);
@@ -88,6 +90,144 @@ async fn set_bind_addr_for_peer_connector(
let _ = connector;
}
struct ResolvedConnectorAddr {
addr: SocketAddr,
ip_version: IpVersion,
}
fn connector_default_port(url: &url::Url) -> Option<u16> {
url.try_into()
.ok()
.and_then(|s: TunnelScheme| s.try_into().ok())
.map(IpScheme::default_port)
}
fn addr_matches_ip_version(addr: &SocketAddr, ip_version: IpVersion) -> bool {
match ip_version {
IpVersion::V4 => addr.is_ipv4(),
IpVersion::V6 => addr.is_ipv6(),
IpVersion::Both => true,
}
}
fn infer_effective_ip_version(addrs: &[SocketAddr], requested_ip_version: IpVersion) -> IpVersion {
match requested_ip_version {
IpVersion::Both if addrs.iter().all(SocketAddr::is_ipv4) => IpVersion::V4,
IpVersion::Both if addrs.iter().all(SocketAddr::is_ipv6) => IpVersion::V6,
_ => requested_ip_version,
}
}
async fn easytier_managed_ipv6_source_for_dst(
global_ctx: &ArcGlobalCtx,
dst_addr: SocketAddrV6,
) -> Result<Option<Ipv6Addr>, Error> {
let socket = {
let _g = global_ctx.net_ns.guard();
tokio::net::UdpSocket::bind("[::]:0").await?
};
socket.connect(SocketAddr::V6(dst_addr)).await?;
let IpAddr::V6(local_ip) = socket.local_addr()?.ip() else {
return Ok(None);
};
Ok(global_ctx
.is_ip_easytier_managed_ipv6(&local_ip)
.then_some(local_ip))
}
async fn ipv6_connector_reject_reason(
url: &url::Url,
global_ctx: &ArcGlobalCtx,
v6_addr: SocketAddrV6,
skip_source_validation_errors: bool,
) -> Result<Option<String>, Error> {
if global_ctx.is_ip_easytier_managed_ipv6(v6_addr.ip()) {
return Ok(Some(format!(
"{} resolves to EasyTier-managed IPv6 {}",
url,
v6_addr.ip()
)));
}
match easytier_managed_ipv6_source_for_dst(global_ctx, v6_addr).await {
Ok(Some(local_ip)) => Ok(Some(format!(
"{} would use EasyTier-managed IPv6 {} as local source for {}",
url, local_ip, v6_addr
))),
Ok(None) => Ok(None),
Err(err) if skip_source_validation_errors => Ok(Some(format!(
"{} IPv6 candidate {} could not be validated: {}",
url, v6_addr, err
))),
Err(err) => Err(err),
}
}
async fn resolve_connector_socket_addr(
url: &url::Url,
global_ctx: &ArcGlobalCtx,
ip_version: IpVersion,
) -> Result<ResolvedConnectorAddr, Error> {
let addrs = socket_addrs(url, || connector_default_port(url))
.await
.map_err(|e| {
TunnelError::InvalidAddr(format!(
"failed to resolve socket addr, url: {}, error: {}",
url, e
))
})?;
let mut usable_addrs = Vec::new();
let mut rejected_ipv6_reason = None;
let skip_source_validation_errors = ip_version == IpVersion::Both;
for addr in addrs
.into_iter()
.filter(|addr| addr_matches_ip_version(addr, ip_version))
{
if let SocketAddr::V6(v6_addr) = addr
&& let Some(reason) = ipv6_connector_reject_reason(
url,
global_ctx,
v6_addr,
skip_source_validation_errors,
)
.await?
{
rejected_ipv6_reason = Some(reason);
continue;
}
usable_addrs.push(addr);
}
if usable_addrs.is_empty() {
if let Some(reason) = rejected_ipv6_reason {
return Err(Error::InvalidUrl(format!(
"{}, refusing overlay-backed underlay connection",
reason
)));
}
return Err(Error::TunnelError(TunnelError::NoDnsRecordFound(
ip_version,
)));
}
let effective_ip_version = infer_effective_ip_version(&usable_addrs, ip_version);
let addr = usable_addrs
.choose(&mut rand::thread_rng())
.copied()
.ok_or_else(|| Error::TunnelError(TunnelError::NoDnsRecordFound(ip_version)))?;
Ok(ResolvedConnectorAddr {
addr,
ip_version: effective_ip_version,
})
}
pub async fn create_connector_by_url(
url: &str,
global_ctx: &ArcGlobalCtx,
@@ -98,9 +238,11 @@ pub async fn create_connector_by_url(
let scheme = (&url)
.try_into()
.map_err(|_| TunnelError::InvalidProtocol(url.scheme().to_owned()))?;
let mut effective_connector_ip_version = ip_version;
let mut connector: Box<dyn TunnelConnector + 'static> = match scheme {
TunnelScheme::Ip(scheme) => {
let dst_addr = SocketAddr::from_url(url.clone(), ip_version).await?;
let resolved_addr = resolve_connector_socket_addr(&url, global_ctx, ip_version).await?;
effective_connector_ip_version = resolved_addr.ip_version;
let mut connector: Box<dyn TunnelConnector> = match scheme {
IpScheme::Tcp => TcpTunnelConnector::new(url).boxed(),
IpScheme::Udp => UdpTunnelConnector::new(url).boxed(),
@@ -125,11 +267,12 @@ pub async fn create_connector_by_url(
#[cfg(feature = "faketcp")]
IpScheme::FakeTcp => tunnel::fake_tcp::FakeTcpTunnelConnector::new(url).boxed(),
};
connector.set_resolved_addr(resolved_addr.addr);
if global_ctx.config.get_flags().bind_device {
set_bind_addr_for_peer_connector(
&mut connector,
dst_addr.is_ipv4(),
&global_ctx.get_ip_collector(),
resolved_addr.addr.is_ipv4(),
global_ctx,
)
.await;
}
@@ -151,16 +294,38 @@ pub async fn create_connector_by_url(
DnsTunnelConnector::new(url, global_ctx.clone()).boxed()
}
};
connector.set_ip_version(ip_version);
connector.set_ip_version(effective_connector_ip_version);
Ok(connector)
}
#[cfg(test)]
mod tests {
use crate::proto::common::PeerFeatureFlag;
use std::collections::BTreeSet;
use super::{should_background_p2p_with_peer, should_try_p2p_with_peer};
use crate::{
common::global_ctx::tests::get_mock_global_ctx, proto::common::PeerFeatureFlag,
tunnel::IpVersion,
};
use super::{
create_connector_by_url, should_background_p2p_with_peer, should_try_p2p_with_peer,
};
#[tokio::test]
async fn connector_rejects_easytier_managed_ipv6_destination() {
let global_ctx = get_mock_global_ctx();
let public_route: cidr::Ipv6Inet = "2001:db8::2/128".parse().unwrap();
global_ctx.set_public_ipv6_routes(BTreeSet::from([public_route]));
let ret =
create_connector_by_url("tcp://[2001:db8::2]:11010", &global_ctx, IpVersion::V6).await;
assert!(matches!(
ret,
Err(crate::common::error::Error::InvalidUrl(_))
));
}
#[test]
fn lazy_background_p2p_requires_need_p2p() {
+7
View File
@@ -621,6 +621,13 @@ mod tests {
Ok(format!("127.0.0.1:{}", port).parse().unwrap())
}
async fn get_udp_port_mapping_with_socket(
&self,
udp: std::sync::Arc<tokio::net::UdpSocket>,
) -> Result<SocketAddr, Error> {
self.get_udp_port_mapping(udp.local_addr()?.port()).await
}
async fn get_tcp_port_mapping(&self, mut port: u16) -> Result<SocketAddr, Error> {
if port == 0 {
port = 40144;
@@ -6,9 +6,10 @@ use std::{
use anyhow::Context;
use tokio::sync::Mutex;
use tokio_util::task::AbortOnDropHandle;
use crate::{
common::{PeerId, scoped_task::ScopedTask, stun::StunInfoCollectorTrait},
common::{PeerId, stun::StunInfoCollectorTrait},
connector::udp_hole_punch::common::{
HOLE_PUNCH_PACKET_BODY_LEN, UdpHolePunchListener, try_connect_with_socket,
},
@@ -32,7 +33,7 @@ const REMOTE_WAIT_TIME_MS: u64 = 5000;
pub(crate) struct PunchBothEasySymHoleServer {
common: Arc<PunchHoleServerCommon>,
task: Mutex<Option<ScopedTask<()>>>,
task: Mutex<Option<AbortOnDropHandle<()>>>,
}
impl PunchBothEasySymHoleServer {
@@ -161,7 +162,7 @@ impl PunchBothEasySymHoleServer {
}
});
*locked_task = Some(task.into());
*locked_task = Some(AbortOnDropHandle::new(task));
return Ok(SendPunchPacketBothEasySymResponse {
is_busy: false,
base_mapped_addr: Some(cur_mapped_addr.into()),
+262 -62
View File
@@ -6,6 +6,7 @@ use std::{
use crossbeam::atomic::AtomicCell;
use dashmap::{DashMap, DashSet};
use guarden::defer;
use rand::seq::SliceRandom as _;
use tokio::{net::UdpSocket, sync::Mutex, task::JoinSet};
use tracing::{Instrument, Level, instrument};
@@ -13,10 +14,8 @@ use zerocopy::FromBytes as _;
use crate::{
common::{
PeerId, error::Error, global_ctx::ArcGlobalCtx, join_joinset_background, netns::NetNS,
stun::StunInfoCollectorTrait as _,
PeerId, error::Error, global_ctx::ArcGlobalCtx, join_joinset_background, netns::NetNS, upnp,
},
defer,
peers::peer_manager::PeerManager,
proto::common::NatType,
tunnel::{
@@ -27,6 +26,7 @@ use crate::{
};
pub(crate) const HOLE_PUNCH_PACKET_BODY_LEN: u16 = 16;
const MAX_PUBLIC_UDP_HOLE_PUNCH_LISTENERS: usize = 4;
fn generate_shuffled_port_vec() -> Vec<u16> {
let mut rng = rand::thread_rng();
@@ -352,6 +352,8 @@ pub(crate) struct UdpHolePunchListener {
tasks: JoinSet<()>,
running: Arc<AtomicCell<bool>>,
mapped_addr: SocketAddr,
has_port_mapping_lease: bool,
_port_mapping_lease: Option<upnp::UdpPortMappingLease>,
conn_counter: Arc<Box<dyn TunnelConnCounter>>,
listen_time: std::time::Instant,
@@ -360,11 +362,6 @@ pub(crate) struct UdpHolePunchListener {
}
impl UdpHolePunchListener {
async fn get_avail_port() -> Result<u16, Error> {
let socket = UdpSocket::bind("0.0.0.0:0").await?;
Ok(socket.local_addr()?.port())
}
#[instrument(err)]
pub async fn new(peer_mgr: Arc<PeerManager>) -> Result<Self, Error> {
Self::new_ext(peer_mgr, true, None).await
@@ -376,18 +373,24 @@ impl UdpHolePunchListener {
with_mapped_addr: bool,
port: Option<u16>,
) -> Result<Self, Error> {
let port = port.unwrap_or(Self::get_avail_port().await?);
let listen_url = format!("udp://0.0.0.0:{}", port);
let socket = {
let _g = peer_mgr.get_global_ctx().net_ns.guard();
Arc::new(UdpSocket::bind((Ipv4Addr::UNSPECIFIED, port.unwrap_or(0))).await?)
};
let local_port = socket.local_addr()?.port();
let listen_url: url::Url = format!("udp://0.0.0.0:{local_port}").parse().unwrap();
let mapped_addr = if with_mapped_addr {
let gctx = peer_mgr.get_global_ctx();
let stun_info_collect = gctx.get_stun_info_collector();
stun_info_collect.get_udp_port_mapping(port).await?
let (mapped_addr, port_mapping_lease) = if with_mapped_addr {
upnp::resolve_udp_public_addr(peer_mgr.get_global_ctx(), &listen_url, socket.clone())
.await?
} else {
SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(0, 0, 0, 0), port))
(
SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, local_port)),
None,
)
};
let mut listener = UdpTunnelListener::new(listen_url.parse().unwrap());
let mut listener = UdpTunnelListener::new_with_socket(listen_url, socket.clone());
{
let _g = peer_mgr.get_global_ctx().net_ns.guard();
@@ -437,6 +440,8 @@ impl UdpHolePunchListener {
socket,
running,
mapped_addr,
has_port_mapping_lease: port_mapping_lease.is_some(),
_port_mapping_lease: port_mapping_lease,
conn_counter,
listen_time: std::time::Instant::now(),
@@ -517,45 +522,87 @@ impl PunchHoleServerCommon {
pub(crate) async fn select_listener(
&self,
use_new_listener: bool,
prefer_port_mapping: bool,
) -> Option<(Arc<UdpSocket>, SocketAddr)> {
let all_listener_sockets = &self.listeners;
let mut use_last = false;
if all_listener_sockets.lock().await.len() < 16 || use_new_listener {
tracing::warn!("creating new udp hole punching listener");
all_listener_sockets.lock().await.push(
UdpHolePunchListener::new(self.peer_mgr.clone())
.await
.ok()?,
);
use_last = true;
}
let mut locked = all_listener_sockets.lock().await;
let listener = if use_last {
Some(locked.last_mut()?)
} else {
// use the listener that is active most recently
locked
.iter_mut()
.filter(|l| !l.mapped_addr.ip().is_unspecified())
.max_by_key(|listener| listener.last_active_time.load())
let (listener_count, has_reusable_listener, has_port_mapping_listener) = {
let locked = self.listeners.lock().await;
(
locked.len(),
locked.iter().any(can_reuse_public_listener),
locked.iter().any(can_reuse_port_mapping_listener),
)
};
let should_create = should_create_public_listener(
listener_count,
has_reusable_listener,
has_port_mapping_listener,
use_new_listener,
prefer_port_mapping,
);
if listener.is_none() || listener.as_ref().unwrap().mapped_addr.ip().is_unspecified() {
if should_create {
tracing::warn!(
?use_new_listener,
"no available udp hole punching listener with mapped address"
max_listeners = MAX_PUBLIC_UDP_HOLE_PUNCH_LISTENERS,
"creating udp hole punching listener"
);
if !use_new_listener {
return self.select_listener(true).await;
} else {
return None;
match UdpHolePunchListener::new(self.peer_mgr.clone()).await {
Ok(listener) => self.listeners.lock().await.push(listener),
Err(err) => {
tracing::warn!(?err, "failed to create udp hole punching listener");
}
}
}
let listener = listener.unwrap();
let mut locked = self.listeners.lock().await;
let listener_count = locked.len();
let listener_idx = if prefer_port_mapping {
select_reusable_port_mapping_listener_idx(locked.as_slice())
.or_else(|| {
if should_create && locked.last().is_some_and(can_reuse_public_listener) {
Some(locked.len() - 1)
} else {
None
}
})
.or_else(|| select_reusable_public_listener_idx(locked.as_slice()))
} else if should_create {
locked.len().checked_sub(1)
} else {
select_reusable_public_listener_idx(locked.as_slice())
};
let Some(listener_idx) = listener_idx else {
tracing::warn!(
?use_new_listener,
?prefer_port_mapping,
listener_count,
max_listeners = MAX_PUBLIC_UDP_HOLE_PUNCH_LISTENERS,
"no available udp hole punching listener with mapped address"
);
if should_retry_public_listener_selection(
use_new_listener,
listener_count,
prefer_port_mapping,
has_port_mapping_listener,
) {
drop(locked);
return self.select_listener(true, prefer_port_mapping).await;
}
return None;
};
let listener = &mut locked[listener_idx];
if !can_reuse_public_listener(listener) {
tracing::warn!(
?use_new_listener,
?prefer_port_mapping,
listener_count,
max_listeners = MAX_PUBLIC_UDP_HOLE_PUNCH_LISTENERS,
"selected udp hole punching listener is not reusable"
);
return None;
}
Some((listener.get_socket().await, listener.mapped_addr))
}
@@ -572,7 +619,73 @@ impl PunchHoleServerCommon {
}
}
#[tracing::instrument(err, ret(level=Level::DEBUG), skip(ports))]
fn can_reuse_public_listener(listener: &UdpHolePunchListener) -> bool {
listener.running.load() && !listener.mapped_addr.ip().is_unspecified()
}
fn can_reuse_port_mapping_listener(listener: &UdpHolePunchListener) -> bool {
can_reuse_public_listener(listener) && listener.has_port_mapping_lease
}
fn select_reusable_public_listener_idx(listeners: &[UdpHolePunchListener]) -> Option<usize> {
// Reuse the listener that was active most recently.
listeners
.iter()
.enumerate()
.filter(|(_, listener)| can_reuse_public_listener(listener))
.max_by_key(|(_, listener)| listener.last_active_time.load())
.map(|(idx, _)| idx)
}
fn select_reusable_port_mapping_listener_idx(listeners: &[UdpHolePunchListener]) -> Option<usize> {
listeners
.iter()
.enumerate()
.filter(|(_, listener)| can_reuse_port_mapping_listener(listener))
.max_by_key(|(_, listener)| listener.last_active_time.load())
.map(|(idx, _)| idx)
}
fn should_create_public_listener(
current_listener_count: usize,
has_reusable_listener: bool,
has_port_mapping_listener: bool,
force_new_listener: bool,
prefer_port_mapping: bool,
) -> bool {
if current_listener_count >= MAX_PUBLIC_UDP_HOLE_PUNCH_LISTENERS {
return false;
}
if current_listener_count == 0 {
return true;
}
if force_new_listener {
return true;
}
if prefer_port_mapping && !has_port_mapping_listener {
return true;
}
!has_reusable_listener
}
fn should_retry_public_listener_selection(
force_new_listener: bool,
current_listener_count: usize,
prefer_port_mapping: bool,
has_port_mapping_listener: bool,
) -> bool {
if prefer_port_mapping && has_port_mapping_listener {
return false;
}
!force_new_listener && current_listener_count < MAX_PUBLIC_UDP_HOLE_PUNCH_LISTENERS
}
#[tracing::instrument(err, ret(level=Level::DEBUG))]
pub(crate) async fn send_symmetric_hole_punch_packet(
ports: &[u16],
udp: Arc<UdpSocket>,
@@ -606,25 +719,31 @@ async fn check_udp_socket_local_addr(
) -> Result<(), Error> {
let socket = UdpSocket::bind("0.0.0.0:0").await?;
socket.connect(remote_mapped_addr).await?;
if let Ok(local_addr) = socket.local_addr() {
// local_addr should not be equal to virtual ipv4 or virtual ipv6
match local_addr.ip() {
IpAddr::V4(ip) => {
if global_ctx.get_ipv4().map(|ip| ip.address()) == Some(ip) {
return Err(anyhow::anyhow!("local address is virtual ipv4").into());
}
}
IpAddr::V6(ip) => {
if global_ctx.get_ipv6().map(|ip| ip.address()) == Some(ip) {
return Err(anyhow::anyhow!("local address is virtual ipv6").into());
}
}
}
if let Ok(local_addr) = socket.local_addr()
&& let Some(err) = easytier_managed_local_addr_error(&global_ctx, local_addr)
{
return Err(anyhow::anyhow!(err).into());
}
Ok(())
}
fn easytier_managed_local_addr_error(
global_ctx: &ArcGlobalCtx,
local_addr: SocketAddr,
) -> Option<&'static str> {
// local_addr should not be equal to an EasyTier-managed virtual/public address.
match local_addr.ip() {
IpAddr::V4(ip) if global_ctx.get_ipv4().map(|ip| ip.address()) == Some(ip) => {
Some("local address is virtual ipv4")
}
IpAddr::V6(ip) if global_ctx.is_ip_easytier_managed_ipv6(&ip) => {
Some("local address is easytier-managed ipv6")
}
_ => None,
}
}
pub(crate) async fn try_connect_with_socket(
global_ctx: ArcGlobalCtx,
socket: Arc<UdpSocket>,
@@ -647,3 +766,84 @@ pub(crate) async fn try_connect_with_socket(
.await
.map_err(Error::from)
}
#[cfg(test)]
mod tests {
use std::{collections::BTreeSet, net::SocketAddr};
use crate::common::global_ctx::tests::get_mock_global_ctx;
use super::{
MAX_PUBLIC_UDP_HOLE_PUNCH_LISTENERS, easytier_managed_local_addr_error,
should_create_public_listener, should_retry_public_listener_selection,
};
#[tokio::test]
async fn local_addr_check_rejects_easytier_public_ipv6_route() {
let global_ctx = get_mock_global_ctx();
let public_route: cidr::Ipv6Inet = "2001:db8::4/128".parse().unwrap();
global_ctx.set_public_ipv6_routes(BTreeSet::from([public_route]));
let local_addr: SocketAddr = "[2001:db8::4]:1234".parse().unwrap();
assert_eq!(
easytier_managed_local_addr_error(&global_ctx, local_addr),
Some("local address is easytier-managed ipv6")
);
}
#[test]
fn listener_selection_prefers_reuse_before_cap() {
assert!(!should_create_public_listener(1, true, true, false, false));
assert!(!should_create_public_listener(
MAX_PUBLIC_UDP_HOLE_PUNCH_LISTENERS,
true,
true,
false,
false
));
}
#[test]
fn listener_selection_creates_when_empty_or_no_reusable_listener() {
assert!(should_create_public_listener(0, false, false, false, false));
assert!(should_create_public_listener(1, false, false, false, false));
}
#[test]
fn listener_selection_force_new_respects_cap() {
assert!(should_create_public_listener(1, true, true, true, false));
assert!(!should_create_public_listener(
MAX_PUBLIC_UDP_HOLE_PUNCH_LISTENERS,
true,
true,
true,
false
));
}
#[test]
fn listener_selection_prefers_port_mapping_until_available() {
assert!(should_create_public_listener(1, true, false, false, true));
assert!(!should_create_public_listener(1, true, true, false, true));
}
#[test]
fn listener_selection_retry_respects_cap() {
assert!(should_retry_public_listener_selection(
false, 1, false, false
));
assert!(!should_retry_public_listener_selection(
false,
MAX_PUBLIC_UDP_HOLE_PUNCH_LISTENERS,
false,
false
));
assert!(!should_retry_public_listener_selection(
true, 1, false, false
));
assert!(!should_retry_public_listener_selection(
false, 1, true, true
));
}
}
+56 -29
View File
@@ -5,9 +5,10 @@ use std::{
use anyhow::Context;
use tokio::net::UdpSocket;
use tokio_util::task::AbortOnDropHandle;
use crate::{
common::{PeerId, scoped_task::ScopedTask, stun::StunInfoCollectorTrait},
common::{PeerId, upnp},
connector::udp_hole_punch::common::{
HOLE_PUNCH_PACKET_BODY_LEN, UdpSocketArray, try_connect_with_socket,
},
@@ -113,29 +114,7 @@ impl PunchConeHoleClient {
let global_ctx = self.peer_mgr.get_global_ctx();
let udp_array = UdpSocketArray::new(1, global_ctx.net_ns.clone());
let local_socket = {
let _g = self.peer_mgr.get_global_ctx().net_ns.guard();
Arc::new(UdpSocket::bind("0.0.0.0:0").await?)
};
let local_addr = local_socket
.local_addr()
.with_context(|| "failed to get local port from udp array")?;
let local_port = local_addr.port();
drop(local_socket);
let local_mapped_addr = global_ctx
.get_stun_info_collector()
.get_udp_port_mapping(local_port)
.await
.with_context(|| "failed to get udp port mapping")?;
let local_socket = {
let _g = self.peer_mgr.get_global_ctx().net_ns.guard();
Arc::new(UdpSocket::bind(local_addr).await?)
};
// client -> server: tell server the mapped port, server will return the mapped address of listening port.
let rpc_stub = self
.peer_mgr
.get_peer_rpc_mgr()
@@ -149,7 +128,10 @@ impl PunchConeHoleClient {
let resp = rpc_stub
.select_punch_listener(
BaseController::default(),
SelectPunchListenerRequest { force_new: false },
SelectPunchListenerRequest {
force_new: false,
prefer_port_mapping: true,
},
)
.await;
@@ -159,6 +141,24 @@ impl PunchConeHoleClient {
"select_punch_listener response missing listener_mapped_addr"
))?;
let local_socket = {
let _g = self.peer_mgr.get_global_ctx().net_ns.guard();
Arc::new(UdpSocket::bind("0.0.0.0:0").await?)
};
let local_addr = local_socket
.local_addr()
.with_context(|| "failed to get local addr from udp punch socket")?;
let local_listener: url::Url = format!("udp://0.0.0.0:{}", local_addr.port())
.parse()
.unwrap();
let (local_mapped_addr, _local_port_mapping_lease) = upnp::resolve_udp_public_addr(
global_ctx.clone(),
&local_listener,
local_socket.clone(),
)
.await
.with_context(|| "failed to resolve udp public addr for cone hole punch")?;
tracing::debug!(
?local_mapped_addr,
?remote_mapped_addr,
@@ -179,7 +179,7 @@ impl PunchConeHoleClient {
send_from_local().await?;
let scoped_punch_task: ScopedTask<()> = tokio::spawn(async move {
let punch_task = AbortOnDropHandle::new(tokio::spawn(async move {
if let Err(e) = rpc_stub
.send_punch_packet_cone(
BaseController {
@@ -199,8 +199,7 @@ impl PunchConeHoleClient {
{
tracing::error!(?e, "failed to call remote send punch packet");
}
})
.into();
}));
// server: will send some punching resps, total 10 packets.
// client: use the socket to create UdpTunnel with UdpTunnelConnector
@@ -209,7 +208,7 @@ impl PunchConeHoleClient {
while finish_time.is_none() || finish_time.as_ref().unwrap().elapsed().as_millis() < 1000 {
tokio::time::sleep(Duration::from_millis(200)).await;
if finish_time.is_none() && (*scoped_punch_task).is_finished() {
if finish_time.is_none() && punch_task.is_finished() {
finish_time = Some(Instant::now());
}
@@ -246,10 +245,15 @@ impl PunchConeHoleClient {
#[cfg(test)]
pub mod tests {
use std::sync::Arc;
use crate::{
common::upnp::{
reset_udp_port_mapping_attempts_for_test, udp_port_mapping_attempts_for_test,
},
connector::udp_hole_punch::{
UdpHolePunchConnector, tests::create_mock_peer_manager_with_mock_stun,
UdpHolePunchConnector, cone::PunchConeHoleClient,
tests::create_mock_peer_manager_with_mock_stun,
},
peers::tests::{connect_peer_manager, wait_route_appear, wait_route_appear_with_cost},
proto::common::NatType,
@@ -280,4 +284,27 @@ pub mod tests {
.unwrap();
println!("{:?}", p_a.list_routes().await);
}
#[tokio::test]
async fn cone_hole_punch_does_not_create_upnp_mapping_before_listener_rpc_succeeds() {
let p_a = create_mock_peer_manager_with_mock_stun(NatType::Restricted).await;
let p_b = create_mock_peer_manager_with_mock_stun(NatType::PortRestricted).await;
let p_c = create_mock_peer_manager_with_mock_stun(NatType::Restricted).await;
connect_peer_manager(p_a.clone(), p_b.clone()).await;
connect_peer_manager(p_b.clone(), p_c.clone()).await;
wait_route_appear(p_a.clone(), p_c.clone()).await.unwrap();
let mut flags = p_a.get_global_ctx().get_flags();
flags.disable_upnp = false;
p_a.get_global_ctx().set_flags(flags);
reset_udp_port_mapping_attempts_for_test();
let ret = PunchConeHoleClient::new(p_a.clone(), Arc::new(timedmap::TimedMap::new()))
.do_hole_punching(p_c.my_peer_id())
.await;
assert!(ret.is_err());
assert_eq!(udp_port_mapping_attempts_for_test(), 0);
}
}
+9 -1
View File
@@ -88,7 +88,7 @@ impl UdpHolePunchRpc for UdpHolePunchServer {
) -> rpc_types::error::Result<SelectPunchListenerResponse> {
let (_, addr) = self
.common
.select_listener(input.force_new)
.select_listener(input.force_new, input.prefer_port_mapping)
.await
.ok_or(anyhow::anyhow!("no listener available"))?;
@@ -584,6 +584,11 @@ impl UdpHolePunchConnector {
Ok(())
}
#[cfg(test)]
pub async fn run_immediately_for_test(&self) {
self.client.run_immediately().await;
}
}
#[cfg(test)]
@@ -614,6 +619,9 @@ pub mod tests {
udp_nat_type: NatType,
) -> Arc<PeerManager> {
let p_a = create_mock_peer_manager().await;
let mut flags = p_a.get_global_ctx().get_flags();
flags.disable_upnp = true;
p_a.get_global_ctx().set_flags(flags);
replace_stun_info_collector(p_a.clone(), udp_nat_type);
p_a
}

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