Compare 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 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 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
Luna Yao 4b95f44a91 replace derive_builder with bon
c'est meilleur

tauri
2026-05-01 21:53:17 +02: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
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
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
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 Yao 524c8d9987 move hostname function to utils 2026-04-20 00:51:59 +02: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
173 changed files with 9417 additions and 20876 deletions
-3
View File
@@ -42,7 +42,4 @@ EXPOSE 11011/tcp
# wss
EXPOSE 11012/tcp
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=5 \
CMD ["/usr/local/bin/easytier-cli", "--rpc-portal", "127.0.0.1:15888", "--output", "json", "node", "info"]
ENTRYPOINT ["/sbin/tini", "--", "easytier-core"]
-3
View File
@@ -43,6 +43,3 @@ easytier-gui/src-tauri/*.sys
.direnv
.flake-profile
# contrib
go.sum
Generated
+279 -275
View File
File diff suppressed because it is too large Load Diff
@@ -13,5 +13,4 @@ log = "0.4"
android_logger = "0.13"
serde = { version = "1.0.220", features = ["derive"] }
serde_json = "1.0"
easytier = { path = "../../easytier" }
easytier-ffi = { path = "../easytier-ffi", default-features = false, features = ["ffi-dataplane"] }
easytier = { path = "../../easytier" }
@@ -8,7 +8,6 @@
- 📱 原生 Android JNI 支持
- 🔧 支持多种 Android 架构 (arm64-v8a, armeabi-v7a, x86, x86_64)
- 🛡️ 类型安全的 Java 接口
- 🔌 支持通过 JSON 调用已暴露的 EasyTier RPC 查询/管理接口
- 📝 详细的错误处理和日志记录
## 支持的架构
@@ -177,20 +176,6 @@ public class EasyTierManager {
}
```
### 通用 JSON RPC
`EasyTierJNI.callJsonRpc(serviceName, methodName, domainName, payloadJson)` 可以调用已暴露的
EasyTier RPC 服务,payload 和返回值均为 protobuf JSON。该接口不支持
`api.manage.WebClientService`;实例启动、保留、删除、信息收集仍使用专用 JNI API。
```java
String response = EasyTierJNI.callJsonRpc(
"api.logger.LoggerRpcService",
"get_logger_config",
"{}"
);
```
### VPN 服务集成
如果您要在 Android VPN 服务中使用:
@@ -279,4 +264,4 @@ public class EasyTierVpnService extends VpnService {
- [EasyTier 主项目](https://github.com/EasyTier/EasyTier)
- [Android NDK 文档](https://developer.android.com/ndk)
- [Rust JNI 文档](https://docs.rs/jni/)
- [Rust JNI 文档](https://docs.rs/jni/)
@@ -1,17 +0,0 @@
use std::{env, path::PathBuf};
fn main() {
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if !matches!(target_os.as_str(), "android" | "linux") {
return;
}
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
let exports = manifest_dir.join("exports.map");
println!("cargo:rerun-if-changed={}", exports.display());
println!(
"cargo:rustc-cdylib-link-arg=-Wl,--version-script={}",
exports.display()
);
println!("cargo:rustc-cdylib-link-arg=-Wl,--exclude-libs,ALL");
}
@@ -1,7 +0,0 @@
{
global:
Java_com_easytier_jni_EasyTierJNI_*;
Java_com_easytier_jni_EasyTierDataPlaneJNI_*;
local:
*;
};
@@ -1,451 +0,0 @@
package com.easytier.jni
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
/**
* EasyTier data-plane API for Android.
*
* Dataplane APIs do not create or start an EasyTier instance by themselves.
* Start an instance with [EasyTierJNI.runNetworkInstance] first, then pass the
* same `instanceName` to [EasyTierDataPlane.tcpConnect],
* [EasyTierDataPlane.tcpBind], or [EasyTierDataPlane.udpBind]. If that instance
* is not running, the native start call fails and the coroutine wrapper throws
* the last EasyTier FFI error.
*
* Typical setup:
* ```
* val instanceName = "android-dataplane-demo"
* val config = """
* instance_name = "$instanceName"
* ipv4 = "10.144.0.1"
* listeners = ["tcp://0.0.0.0:11010"]
*
* [network_identity]
* network_name = "android-dataplane-demo"
* network_secret = "replace-with-a-real-secret"
*
* [[peer]]
* uri = "tcp://peer.example.com:11010"
*
* [flags]
* no_tun = true
* bind_device = false
* """.trimIndent()
*
* EasyTierJNI.runNetworkInstance(config)
* ```
*
* After the instance is running, most callers should use [EasyTierDataPlane]
* and the socket/stream classes below. [EasyTierDataPlaneJNI] is the low-level
* native op-handle ABI used by the coroutine wrappers.
*
* TCP client usage:
* ```
* val stream = EasyTierDataPlane.tcpConnect(instanceName, "10.144.0.2", 8080, 5_000)
* try {
* stream.write("ping".toByteArray(), 5_000)
* val reply = stream.read(4096, 5_000)
* } finally {
* stream.close()
* }
* ```
*
* TCP server usage:
* ```
* val listener = EasyTierDataPlane.tcpBind(instanceName, 8080, 5_000)
* try {
* val stream = listener.accept(30_000)
* try {
* stream.write(stream.read(4096, 5_000), 5_000)
* } finally {
* stream.close()
* }
* } finally {
* listener.close()
* }
* ```
*
* UDP usage:
* ```
* val socket = EasyTierDataPlane.udpBind(instanceName, 0, 5_000)
* try {
* socket.sendTo("10.144.0.2", 9000, "ping".toByteArray(), 5_000)
* val packet = socket.recvFrom(4096, 5_000)
* } finally {
* socket.close()
* }
* ```
*
* Operation model:
* - Each suspend function starts one native async op, waits on Dispatchers.IO,
* then consumes the op with the matching finish call.
* - Coroutine cancellation cancels and frees the native op.
* - Returned stream/listener/socket handles must be closed by the caller.
* - Input ByteArray data is copied by the native start call; output data is
* copied into Kotlin ByteArray before the native buffer is freed.
*/
/** Data-plane IPv4/port pair returned by EasyTier FFI. */
data class DataPlaneSocketAddress(val ip: String, val port: Int)
/** Result of a completed TCP connect op. */
data class DataPlaneTcpConnectResult(val handle: Long, val localAddress: DataPlaneSocketAddress)
/** Result of a completed TCP bind op. */
data class DataPlaneTcpBindResult(val handle: Long, val localAddress: DataPlaneSocketAddress)
/** Result of a completed TCP accept op. */
data class DataPlaneTcpAcceptResult(
val handle: Long,
val localAddress: DataPlaneSocketAddress,
val peerAddress: DataPlaneSocketAddress
)
/** Result of a completed TCP read op. */
data class DataPlaneTcpReadResult(val data: ByteArray)
/** Result of a completed UDP bind op. */
data class DataPlaneUdpBindResult(val handle: Long, val localAddress: DataPlaneSocketAddress)
/** Result of a completed UDP recv_from op. */
data class DataPlaneUdpRecvResult(
val data: ByteArray,
val peerAddress: DataPlaneSocketAddress
)
/** TCP data-plane stream handle. Call [close] when the stream is no longer needed. */
class DataPlaneTcpStream(
val handle: Long,
val localAddress: DataPlaneSocketAddress? = null,
val peerAddress: DataPlaneSocketAddress? = null
) {
/** Read up to [maxLength] bytes, waiting at most [timeoutMs] in native code. */
suspend fun read(maxLength: Int, timeoutMs: Long): ByteArray =
EasyTierDataPlane.tcpRead(this, maxLength, timeoutMs)
/** Write [data], waiting at most [timeoutMs] in native code. */
suspend fun write(data: ByteArray, timeoutMs: Long): Int =
EasyTierDataPlane.tcpWrite(this, data, timeoutMs)
/** Close the native TCP stream handle. */
fun close(): Int = EasyTierDataPlaneJNI.dataPlaneTcpClose(handle)
}
/** TCP data-plane listener handle. Call [close] when the listener is no longer needed. */
class DataPlaneTcpListener(val handle: Long, val localAddress: DataPlaneSocketAddress) {
/** Accept one TCP data-plane stream. */
suspend fun accept(timeoutMs: Long): DataPlaneTcpStream =
EasyTierDataPlane.tcpAccept(this, timeoutMs)
/** Close the native TCP listener handle. */
fun close(): Int = EasyTierDataPlaneJNI.dataPlaneTcpListenerClose(handle)
}
/** UDP data-plane socket handle. Call [close] when the socket is no longer needed. */
class DataPlaneUdpSocket(val handle: Long, val localAddress: DataPlaneSocketAddress) {
/** Send one UDP datagram to [dstIp]:[dstPort]. */
suspend fun sendTo(
dstIp: String,
dstPort: Int,
data: ByteArray,
timeoutMs: Long
): Int = EasyTierDataPlane.udpSendTo(this, dstIp, dstPort, data, timeoutMs)
/** Receive one UDP datagram and its peer address. */
suspend fun recvFrom(maxLength: Int, timeoutMs: Long): DataPlaneUdpRecvResult =
EasyTierDataPlane.udpRecvFrom(this, maxLength, timeoutMs)
/** Close the native UDP socket handle. */
fun close(): Int = EasyTierDataPlaneJNI.dataPlaneUdpClose(handle)
}
/**
* Low-level native data-plane JNI entry points.
*
* These functions mirror the Rust FFI op-handle ABI directly. They are exposed
* for completeness, but most Android callers should use [EasyTierDataPlane]
* instead so coroutine cancellation and op cleanup are handled consistently.
*/
object EasyTierDataPlaneJNI {
init {
System.loadLibrary("easytier_android_jni")
}
@JvmStatic external fun dataPlaneAsyncOpStatus(handle: Long): Int
@JvmStatic external fun dataPlaneAsyncOpWait(handle: Long, timeoutMs: Long): Int
@JvmStatic external fun dataPlaneAsyncOpCancel(handle: Long): Int
@JvmStatic external fun dataPlaneAsyncOpFree(handle: Long): Int
@JvmStatic
external fun dataPlaneTcpConnectStart(
instanceName: String,
dstIp: String,
dstPort: Int,
timeoutMs: Long
): Long
@JvmStatic external fun dataPlaneTcpConnectFinish(op: Long): DataPlaneTcpConnectResult?
@JvmStatic
external fun dataPlaneTcpBindStart(
instanceName: String,
localPort: Int,
timeoutMs: Long
): Long
@JvmStatic external fun dataPlaneTcpBindFinish(op: Long): DataPlaneTcpBindResult?
@JvmStatic external fun dataPlaneTcpAcceptStart(handle: Long, timeoutMs: Long): Long
@JvmStatic external fun dataPlaneTcpAcceptFinish(op: Long): DataPlaneTcpAcceptResult?
@JvmStatic external fun dataPlaneTcpReadStart(handle: Long, maxLength: Int, timeoutMs: Long): Long
@JvmStatic external fun dataPlaneTcpReadFinish(op: Long): DataPlaneTcpReadResult?
@JvmStatic external fun dataPlaneTcpWriteStart(handle: Long, data: ByteArray, timeoutMs: Long): Long
@JvmStatic external fun dataPlaneTcpWriteFinish(op: Long): Int
@JvmStatic
external fun dataPlaneUdpBindStart(
instanceName: String,
localPort: Int,
timeoutMs: Long
): Long
@JvmStatic external fun dataPlaneUdpBindFinish(op: Long): DataPlaneUdpBindResult?
@JvmStatic
external fun dataPlaneUdpSendToStart(
handle: Long,
dstIp: String,
dstPort: Int,
data: ByteArray,
timeoutMs: Long
): Long
@JvmStatic external fun dataPlaneUdpSendToFinish(op: Long): Int
@JvmStatic external fun dataPlaneUdpRecvFromStart(handle: Long, maxLength: Int, timeoutMs: Long): Long
@JvmStatic external fun dataPlaneUdpRecvFromFinish(op: Long): DataPlaneUdpRecvResult?
@JvmStatic external fun dataPlaneTcpClose(handle: Long): Int
@JvmStatic external fun dataPlaneTcpListenerClose(handle: Long): Int
@JvmStatic external fun dataPlaneUdpClose(handle: Long): Int
}
/** Coroutine-friendly Android data-plane API. */
object EasyTierDataPlane {
private const val DATA_PLANE_OP_PENDING = 0
private const val DATA_PLANE_OP_READY = 1
private const val DATA_PLANE_OP_FAILED = -1
private const val DATA_PLANE_OP_INVALID = -2
private const val DATA_PLANE_WAIT_SLICE_MS = 50L
/** Connect to a TCP endpoint through the named EasyTier instance. */
@JvmStatic
suspend fun tcpConnect(
instanceName: String,
dstIp: String,
dstPort: Int,
timeoutMs: Long
): DataPlaneTcpStream {
val op =
requireOp(
EasyTierDataPlaneJNI.dataPlaneTcpConnectStart(
instanceName,
dstIp,
dstPort,
timeoutMs
)
)
val result = awaitOp(op) {
EasyTierDataPlaneJNI.dataPlaneTcpConnectFinish(it) ?: throw lastDataPlaneException()
}
return DataPlaneTcpStream(result.handle, result.localAddress)
}
/** Bind a TCP data-plane listener on [localPort]. Port 0 asks EasyTier to allocate one. */
@JvmStatic
suspend fun tcpBind(
instanceName: String,
localPort: Int,
timeoutMs: Long
): DataPlaneTcpListener {
val op =
requireOp(
EasyTierDataPlaneJNI.dataPlaneTcpBindStart(
instanceName,
localPort,
timeoutMs
)
)
val result = awaitOp(op) {
EasyTierDataPlaneJNI.dataPlaneTcpBindFinish(it) ?: throw lastDataPlaneException()
}
return DataPlaneTcpListener(result.handle, result.localAddress)
}
/** Accept one TCP stream from [listener]. */
@JvmStatic
suspend fun tcpAccept(listener: DataPlaneTcpListener, timeoutMs: Long): DataPlaneTcpStream {
val op =
requireOp(
EasyTierDataPlaneJNI.dataPlaneTcpAcceptStart(listener.handle, timeoutMs)
)
val result = awaitOp(op) {
EasyTierDataPlaneJNI.dataPlaneTcpAcceptFinish(it) ?: throw lastDataPlaneException()
}
return DataPlaneTcpStream(result.handle, result.localAddress, result.peerAddress)
}
/** Read up to [maxLength] bytes from [stream]. */
@JvmStatic
suspend fun tcpRead(
stream: DataPlaneTcpStream,
maxLength: Int,
timeoutMs: Long
): ByteArray {
val op =
requireOp(
EasyTierDataPlaneJNI.dataPlaneTcpReadStart(
stream.handle,
maxLength,
timeoutMs
)
)
return awaitOp(op) {
EasyTierDataPlaneJNI.dataPlaneTcpReadFinish(it)?.data
?: throw lastDataPlaneException()
}
}
/** Write [data] to [stream]. */
@JvmStatic
suspend fun tcpWrite(stream: DataPlaneTcpStream, data: ByteArray, timeoutMs: Long): Int {
val op =
requireOp(
EasyTierDataPlaneJNI.dataPlaneTcpWriteStart(
stream.handle,
data,
timeoutMs
)
)
return awaitOp(op) { EasyTierDataPlaneJNI.dataPlaneTcpWriteFinish(it) }
}
/** Bind a UDP data-plane socket on [localPort]. Port 0 asks EasyTier to allocate one. */
@JvmStatic
suspend fun udpBind(
instanceName: String,
localPort: Int,
timeoutMs: Long
): DataPlaneUdpSocket {
val op =
requireOp(
EasyTierDataPlaneJNI.dataPlaneUdpBindStart(
instanceName,
localPort,
timeoutMs
)
)
val result = awaitOp(op) {
EasyTierDataPlaneJNI.dataPlaneUdpBindFinish(it) ?: throw lastDataPlaneException()
}
return DataPlaneUdpSocket(result.handle, result.localAddress)
}
/** Send one UDP datagram through [socket]. */
@JvmStatic
suspend fun udpSendTo(
socket: DataPlaneUdpSocket,
dstIp: String,
dstPort: Int,
data: ByteArray,
timeoutMs: Long
): Int {
val op =
requireOp(
EasyTierDataPlaneJNI.dataPlaneUdpSendToStart(
socket.handle,
dstIp,
dstPort,
data,
timeoutMs
)
)
return awaitOp(op) { EasyTierDataPlaneJNI.dataPlaneUdpSendToFinish(it) }
}
/** Receive one UDP datagram through [socket]. */
@JvmStatic
suspend fun udpRecvFrom(
socket: DataPlaneUdpSocket,
maxLength: Int,
timeoutMs: Long
): DataPlaneUdpRecvResult {
val op =
requireOp(
EasyTierDataPlaneJNI.dataPlaneUdpRecvFromStart(
socket.handle,
maxLength,
timeoutMs
)
)
return awaitOp(op) {
EasyTierDataPlaneJNI.dataPlaneUdpRecvFromFinish(it) ?: throw lastDataPlaneException()
}
}
private fun requireOp(op: Long): Long {
if (op == 0L) {
throw lastDataPlaneException()
}
return op
}
private suspend fun <T> awaitOp(op: Long, finish: (Long) -> T): T =
withContext(Dispatchers.IO) {
var consumed = false
try {
awaitReady(op)
val result = finish(op)
consumed = true
result
} catch (e: CancellationException) {
EasyTierDataPlaneJNI.dataPlaneAsyncOpCancel(op)
throw e
} finally {
if (!consumed) {
EasyTierDataPlaneJNI.dataPlaneAsyncOpFree(op)
}
}
}
private suspend fun awaitReady(op: Long) {
while (true) {
currentCoroutineContext().ensureActive()
when (EasyTierDataPlaneJNI.dataPlaneAsyncOpWait(op, DATA_PLANE_WAIT_SLICE_MS)) {
DATA_PLANE_OP_READY, DATA_PLANE_OP_FAILED -> return
DATA_PLANE_OP_PENDING -> Unit
DATA_PLANE_OP_INVALID -> throw RuntimeException("Data-plane async operation is invalid")
else -> throw RuntimeException("Unknown data-plane async operation status")
}
}
}
private fun lastDataPlaneException(): RuntimeException {
return RuntimeException(EasyTierJNI.getLastError() ?: "EasyTier data-plane call failed")
}
}
@@ -1,11 +1,8 @@
package com.easytier.jni
fun interface ConfigServerEventCallback {
fun onEvent(eventJson: String)
}
/** EasyTier JNI 接口类 提供 Android 应用调用 EasyTier 核心网络功能的接口 */
/** EasyTier JNI 接口类 提供 Android 应用调用 EasyTier 网络功能的接口 */
object EasyTierJNI {
init {
// 加载本地库
System.loadLibrary("easytier_android_jni")
@@ -36,35 +33,6 @@ object EasyTierJNI {
*/
@JvmStatic external fun runNetworkInstance(config: String): Int
/**
* 启动配置服务器客户端
* @param url 配置服务器 URL
* @param hostname 主机名,传入 null 使用系统主机名
* @param machineId 稳定机器 ID,由调用方负责持久化
* @param secureMode 是否启用 secure mode
* @param callback 远程配置应用/删除事件回调
* @return 0 表示成功,-1 表示失败
* @throws RuntimeException 当客户端启动失败时抛出异常
*/
@JvmStatic
external fun startConfigServerClient(
url: String,
hostname: String?,
machineId: String,
secureMode: Boolean,
callback: ConfigServerEventCallback?
): Int
/**
* 停止配置服务器客户端
* @return 0 表示成功,-1 表示失败
* @throws RuntimeException 当客户端停止失败时抛出异常
*/
@JvmStatic external fun stopConfigServerClient(): Int
/** 查询配置服务器客户端是否已连接 */
@JvmStatic external fun isConfigServerClientConnected(): Boolean
/**
* 保留指定的网络实例,停止其他实例
* @param instanceNames 要保留的实例名称数组,传入 null 或空数组将停止所有实例
@@ -76,48 +44,11 @@ object EasyTierJNI {
/**
* 收集网络信息
* @param maxLength 最大返回条目数
* @return 包含网络信息的 JSON 字符串
* @return 包含网络信息的字符串数组,每个元素格式为 "key=value"
* @throws RuntimeException 当操作失败时抛出异常
*/
@JvmStatic external fun collectNetworkInfos(maxLength: Int): String?
/**
* 列出当前运行的实例名称和实例 ID。
* @param maxLength 最大返回条目数
* @return JSON 对象,key 为 instance namevalue 为 instance id
* @throws RuntimeException 当操作失败时抛出异常
*/
@JvmStatic external fun listInstances(maxLength: Int): String?
/**
* 调用暴露的 EasyTier RPC 方法,输入和输出均为 protobuf JSON 字符串。
*
* 不支持 api.manage.WebClientService;实例启动、保留、删除、信息收集请继续使用专用 JNI API。
* payloadJson 需要包含目标 RPC 所需的 instance selector。
*
* @param serviceName RPC 服务名,例如 api.instance.PeerManageRpcService
* @param methodName RPC 方法名,支持 snake_case 或 proto 方法名
* @param domainName 仅 TcpProxyRpcService 使用;传 null 或空字符串默认 tcp
* @param payloadJson protobuf JSON 请求体
* @return protobuf JSON 响应体
* @throws RuntimeException 当 RPC 调用失败时抛出异常
*/
@JvmStatic
external fun callJsonRpc(
serviceName: String,
methodName: String,
domainName: String?,
payloadJson: String
): String?
/**
* 调用不需要 domainName 的 EasyTier RPC 方法。
*/
@JvmStatic
fun callJsonRpc(serviceName: String, methodName: String, payloadJson: String): String? {
return callJsonRpc(serviceName, methodName, null, payloadJson)
}
/**
* 获取最后的错误消息
* @return 错误消息字符串,如果没有错误则返回 null
@@ -1,124 +0,0 @@
use std::{
ffi::{CStr, c_char, c_void},
sync::{Arc, Mutex, MutexGuard},
};
use easytier_ffi::ConfigServerEventCallback;
use jni::JNIEnv;
use jni::objects::{GlobalRef, JObject, JValue};
use once_cell::sync::Lazy;
use crate::error;
pub(crate) struct JniConfigServerCallback {
java_vm: jni::JavaVM,
callback: GlobalRef,
}
static CONFIG_SERVER_CALLBACK: Lazy<Mutex<Option<Arc<JniConfigServerCallback>>>> =
Lazy::new(|| Mutex::new(None));
pub(crate) fn lock_callback_storage()
-> Result<MutexGuard<'static, Option<Arc<JniConfigServerCallback>>>, String> {
CONFIG_SERVER_CALLBACK
.lock()
.map_err(|e| format!("Failed to lock config server callback: {}", e))
}
pub(crate) fn new_callback(
env: &mut JNIEnv,
callback: &JObject,
) -> Result<Arc<JniConfigServerCallback>, String> {
let java_vm = env
.get_java_vm()
.map_err(|e| format!("Failed to get JavaVM: {:?}", e))?;
let callback = env
.new_global_ref(callback)
.map_err(|e| format!("Failed to create callback global ref: {:?}", e))?;
Ok(Arc::new(JniConfigServerCallback { java_vm, callback }))
}
pub(crate) fn callback_fn(
callback: &Option<Arc<JniConfigServerCallback>>,
) -> ConfigServerEventCallback {
callback
.as_ref()
.map(|_| config_server_event_callback as unsafe extern "C" fn(*const c_char, *mut c_void))
}
pub(crate) fn user_data(callback: &Option<Arc<JniConfigServerCallback>>) -> *mut c_void {
callback
.as_ref()
.map(|callback| Arc::as_ptr(callback) as *mut c_void)
.unwrap_or(std::ptr::null_mut())
}
impl JniConfigServerCallback {
fn clear_pending_exception(
env: &mut JNIEnv,
context: &str,
error: &dyn std::fmt::Debug,
) -> String {
match env.exception_check() {
Ok(true) => {
if let Err(clear_err) = env.exception_clear() {
return format!(
"{}: {:?}; failed to clear pending Java exception: {:?}",
context, error, clear_err
);
}
}
Ok(false) => {}
Err(check_err) => {
return format!(
"{}: {:?}; failed to check pending Java exception: {:?}",
context, error, check_err
);
}
}
format!("{}: {:?}", context, error)
}
fn on_event(&self, event_json: *const c_char) -> Result<(), String> {
let event_json = unsafe { CStr::from_ptr(event_json) }
.to_str()
.map_err(|e| format!("Invalid config server event JSON: {:?}", e))?;
let mut env = self
.java_vm
.attach_current_thread()
.map_err(|e| format!("Failed to attach callback thread: {:?}", e))?;
let event_json = env.new_string(event_json).map_err(|e| {
Self::clear_pending_exception(&mut env, "Failed to create event string", &e)
})?;
if let Err(e) = env.call_method(
self.callback.as_obj(),
"onEvent",
"(Ljava/lang/String;)V",
&[JValue::from(&event_json)],
) {
return Err(Self::clear_pending_exception(
&mut env,
"Failed to call config server callback",
&e,
));
}
Ok(())
}
}
unsafe extern "C" fn config_server_event_callback(
event_json: *const c_char,
user_data: *mut c_void,
) {
if event_json.is_null() || user_data.is_null() {
return;
}
let callback = unsafe { &*(user_data as *const JniConfigServerCallback) };
if let Err(error) = callback.on_event(event_json) {
error::set_callback_error(error);
}
}
@@ -1,140 +0,0 @@
use std::ptr;
use easytier_ffi::{
in_config_server_callback, is_config_server_client_connected, start_config_server_client,
stop_config_server_client,
};
use jni::JNIEnv;
use jni::objects::{JClass, JObject, JString};
use jni::sys::{JNI_FALSE, JNI_TRUE, jboolean, jint};
use crate::{
callback, error,
strings::{jstring_to_cstring, optional_jstring_to_cstring},
};
pub(crate) fn start_config_server_client_jni(
env: &mut JNIEnv,
config_server_url: JString,
hostname: JString,
machine_id: JString,
secure_mode: jboolean,
callback_obj: JObject,
) -> jint {
if in_config_server_callback() {
error::throw_exception(
env,
"Cannot start config server client from config server callback",
);
return -1;
}
let config_server_url = match jstring_to_cstring(env, &config_server_url) {
Ok(cstr) => cstr,
Err(e) => {
error::throw_exception(env, &format!("Invalid config server URL: {}", e));
return -1;
}
};
let hostname = match optional_jstring_to_cstring(env, &hostname) {
Ok(cstr) => cstr,
Err(e) => {
error::throw_exception(env, &format!("Invalid hostname: {}", e));
return -1;
}
};
let machine_id = match jstring_to_cstring(env, &machine_id) {
Ok(cstr) => cstr,
Err(e) => {
error::throw_exception(env, &format!("Invalid machine ID: {}", e));
return -1;
}
};
let callback_ref = if callback_obj.is_null() {
None
} else {
match callback::new_callback(env, &callback_obj) {
Ok(state) => Some(state),
Err(e) => {
error::throw_exception(env, &e);
return -1;
}
}
};
let mut callback_guard = match callback::lock_callback_storage() {
Ok(guard) => guard,
Err(e) => {
error::throw_exception(env, &e);
return -1;
}
};
if callback_guard.is_none() {
error::clear_callback_error();
}
let callback_fn = callback::callback_fn(&callback_ref);
let user_data = callback::user_data(&callback_ref);
let result = unsafe {
start_config_server_client(
config_server_url.as_ptr(),
hostname
.as_ref()
.map(|value| value.as_ptr())
.unwrap_or(ptr::null()),
machine_id.as_ptr(),
secure_mode == JNI_TRUE,
callback_fn,
user_data,
)
};
if result != 0 {
if let Some(error_msg) = error::get_last_error() {
error::throw_exception(env, &error_msg);
}
return result;
}
*callback_guard = callback_ref;
result
}
pub(crate) fn stop_config_server_client_jni(mut env: JNIEnv, _class: JClass) -> jint {
if in_config_server_callback() {
let result = stop_config_server_client();
if result != 0
&& let Some(error_msg) = error::get_last_error()
{
error::throw_exception(&mut env, &error_msg);
}
return result;
}
let mut callback_guard = match callback::lock_callback_storage() {
Ok(guard) => guard,
Err(e) => {
error::throw_exception(&mut env, &e);
return -1;
}
};
let result = stop_config_server_client();
if result != 0 {
if let Some(error_msg) = error::get_last_error() {
error::throw_exception(&mut env, &error_msg);
}
return result;
}
*callback_guard = None;
result
}
pub(crate) fn is_config_server_client_connected_jni(_env: JNIEnv, _class: JClass) -> jboolean {
if is_config_server_client_connected() != 0 {
JNI_TRUE
} else {
JNI_FALSE
}
}
@@ -1,673 +0,0 @@
use std::{
ffi::{CStr, c_char},
ptr,
};
use easytier_ffi::{
data_plane_async_op_cancel, data_plane_async_op_free, data_plane_async_op_status,
data_plane_async_op_wait, data_plane_free_bytes, data_plane_tcp_accept_finish,
data_plane_tcp_accept_start, data_plane_tcp_bind_finish, data_plane_tcp_bind_start,
data_plane_tcp_close, data_plane_tcp_connect_finish, data_plane_tcp_connect_start,
data_plane_tcp_listener_close, data_plane_tcp_read_finish, data_plane_tcp_read_start,
data_plane_tcp_write_finish, data_plane_tcp_write_start, data_plane_udp_bind_finish,
data_plane_udp_bind_start, data_plane_udp_close, data_plane_udp_recv_from_finish,
data_plane_udp_recv_from_start, data_plane_udp_send_to_finish, data_plane_udp_send_to_start,
free_string,
};
use jni::{
JNIEnv,
objects::{JByteArray, JClass, JObject, JString, JValue},
sys::{jint, jlong, jobject},
};
use crate::{
error::{get_last_error, throw_exception},
strings::jstring_to_cstring,
};
const SOCKET_ADDR_CLASS: &str = "com/easytier/jni/DataPlaneSocketAddress";
const TCP_CONNECT_RESULT_CLASS: &str = "com/easytier/jni/DataPlaneTcpConnectResult";
const TCP_BIND_RESULT_CLASS: &str = "com/easytier/jni/DataPlaneTcpBindResult";
const TCP_ACCEPT_RESULT_CLASS: &str = "com/easytier/jni/DataPlaneTcpAcceptResult";
const TCP_READ_RESULT_CLASS: &str = "com/easytier/jni/DataPlaneTcpReadResult";
const UDP_BIND_RESULT_CLASS: &str = "com/easytier/jni/DataPlaneUdpBindResult";
const UDP_RECV_RESULT_CLASS: &str = "com/easytier/jni/DataPlaneUdpRecvResult";
fn timeout_from_jlong(timeout_ms: jlong) -> u64 {
timeout_ms.max(0) as u64
}
fn port_from_jint(env: &mut JNIEnv, value: jint, name: &str) -> Option<u16> {
match u16::try_from(value) {
Ok(port) => Some(port),
Err(_) => {
throw_exception(env, &format!("Invalid {}: {}", name, value));
None
}
}
}
fn len_from_jint(env: &mut JNIEnv, value: jint, name: &str) -> Option<u32> {
match u32::try_from(value) {
Ok(len) => Some(len),
Err(_) => {
throw_exception(env, &format!("Invalid {}: {}", name, value));
None
}
}
}
fn throw_last(env: &mut JNIEnv) {
let message = get_last_error().unwrap_or_else(|| "EasyTier data-plane call failed".to_string());
throw_exception(env, &message);
}
unsafe fn take_ffi_string(ptr: *const c_char) -> String {
if ptr.is_null() {
return String::new();
}
let value = unsafe { CStr::from_ptr(ptr) }
.to_string_lossy()
.into_owned();
free_string(ptr);
value
}
fn new_socket_addr<'local>(
env: &mut JNIEnv<'local>,
ip: String,
port: u16,
) -> Option<JObject<'local>> {
let class = match env.find_class(SOCKET_ADDR_CLASS) {
Ok(class) => class,
Err(err) => {
throw_exception(
env,
&format!("Failed to find socket address class: {:?}", err),
);
return None;
}
};
let ip = match env.new_string(ip) {
Ok(ip) => ip,
Err(err) => {
throw_exception(env, &format!("Failed to create IP string: {:?}", err));
return None;
}
};
match env.new_object(
class,
"(Ljava/lang/String;I)V",
&[JValue::Object(&ip), JValue::Int(port as jint)],
) {
Ok(addr) => Some(addr),
Err(err) => {
throw_exception(env, &format!("Failed to create socket address: {:?}", err));
None
}
}
}
fn new_handle_addr_result(
env: &mut JNIEnv,
class_name: &str,
handle: u64,
ip: String,
port: u16,
) -> jobject {
let Some(addr) = new_socket_addr(env, ip, port) else {
return ptr::null_mut();
};
let class = match env.find_class(class_name) {
Ok(class) => class,
Err(err) => {
throw_exception(env, &format!("Failed to find result class: {:?}", err));
return ptr::null_mut();
}
};
let sig = format!("(JL{};)V", SOCKET_ADDR_CLASS);
match env.new_object(
class,
sig.as_str(),
&[JValue::Long(handle as jlong), JValue::Object(&addr)],
) {
Ok(result) => result.into_raw(),
Err(err) => {
throw_exception(env, &format!("Failed to create result object: {:?}", err));
ptr::null_mut()
}
}
}
fn close_tcp_stream_on_null(result: jobject, handle: u64) -> jobject {
if result.is_null() {
let _ = data_plane_tcp_close(handle);
}
result
}
fn close_tcp_listener_on_null(result: jobject, handle: u64) -> jobject {
if result.is_null() {
let _ = data_plane_tcp_listener_close(handle);
}
result
}
fn close_udp_socket_on_null(result: jobject, handle: u64) -> jobject {
if result.is_null() {
let _ = data_plane_udp_close(handle);
}
result
}
fn read_owned_bytes(ptr: *const u8, len: u32) -> Vec<u8> {
if ptr.is_null() || len == 0 {
return Vec::new();
}
let bytes = unsafe { std::slice::from_raw_parts(ptr, len as usize) }.to_vec();
data_plane_free_bytes(ptr, len);
bytes
}
pub(crate) fn async_op_status_jni(_env: JNIEnv, _class: JClass, handle: jlong) -> jint {
data_plane_async_op_status(handle as u64)
}
pub(crate) fn async_op_wait_jni(
_env: JNIEnv,
_class: JClass,
handle: jlong,
timeout_ms: jlong,
) -> jint {
data_plane_async_op_wait(handle as u64, timeout_ms.max(0) as u64)
}
pub(crate) fn async_op_cancel_jni(_env: JNIEnv, _class: JClass, handle: jlong) -> jint {
data_plane_async_op_cancel(handle as u64)
}
pub(crate) fn async_op_free_jni(_env: JNIEnv, _class: JClass, handle: jlong) -> jint {
data_plane_async_op_free(handle as u64)
}
pub(crate) fn tcp_connect_start_jni(
mut env: JNIEnv,
_class: JClass,
inst_name: JString,
dst_ip: JString,
dst_port: jint,
timeout_ms: jlong,
) -> jlong {
let inst_name = match jstring_to_cstring(&mut env, &inst_name) {
Ok(value) => value,
Err(err) => {
throw_exception(&mut env, &format!("Invalid instance name: {}", err));
return 0;
}
};
let dst_ip = match jstring_to_cstring(&mut env, &dst_ip) {
Ok(value) => value,
Err(err) => {
throw_exception(&mut env, &format!("Invalid destination IP: {}", err));
return 0;
}
};
let Some(dst_port) = port_from_jint(&mut env, dst_port, "destination port") else {
return 0;
};
let op = unsafe {
data_plane_tcp_connect_start(
inst_name.as_ptr(),
dst_ip.as_ptr(),
dst_port,
timeout_ms.max(0) as u64,
)
};
if op == 0 {
throw_last(&mut env);
}
op as jlong
}
pub(crate) fn tcp_connect_finish_jni(mut env: JNIEnv, _class: JClass, op: jlong) -> jobject {
let mut ip: *const c_char = ptr::null();
let mut port = 0u16;
let handle = unsafe { data_plane_tcp_connect_finish(op as u64, &mut ip, &mut port) };
if handle == 0 {
throw_last(&mut env);
return ptr::null_mut();
}
close_tcp_stream_on_null(
new_handle_addr_result(
&mut env,
TCP_CONNECT_RESULT_CLASS,
handle,
unsafe { take_ffi_string(ip) },
port,
),
handle,
)
}
pub(crate) fn tcp_bind_start_jni(
mut env: JNIEnv,
_class: JClass,
inst_name: JString,
local_port: jint,
timeout_ms: jlong,
) -> jlong {
let inst_name = match jstring_to_cstring(&mut env, &inst_name) {
Ok(value) => value,
Err(err) => {
throw_exception(&mut env, &format!("Invalid instance name: {}", err));
return 0;
}
};
let Some(local_port) = port_from_jint(&mut env, local_port, "local port") else {
return 0;
};
let op = unsafe {
data_plane_tcp_bind_start(
inst_name.as_ptr(),
local_port,
timeout_from_jlong(timeout_ms),
)
};
if op == 0 {
throw_last(&mut env);
}
op as jlong
}
pub(crate) fn tcp_bind_finish_jni(mut env: JNIEnv, _class: JClass, op: jlong) -> jobject {
let mut ip: *const c_char = ptr::null();
let mut port = 0u16;
let handle = unsafe { data_plane_tcp_bind_finish(op as u64, &mut ip, &mut port) };
if handle == 0 {
throw_last(&mut env);
return ptr::null_mut();
}
close_tcp_listener_on_null(
new_handle_addr_result(
&mut env,
TCP_BIND_RESULT_CLASS,
handle,
unsafe { take_ffi_string(ip) },
port,
),
handle,
)
}
pub(crate) fn tcp_accept_start_jni(
mut env: JNIEnv,
_class: JClass,
handle: jlong,
timeout_ms: jlong,
) -> jlong {
let op = unsafe { data_plane_tcp_accept_start(handle as u64, timeout_from_jlong(timeout_ms)) };
if op == 0 {
throw_last(&mut env);
}
op as jlong
}
pub(crate) fn tcp_accept_finish_jni(mut env: JNIEnv, _class: JClass, op: jlong) -> jobject {
let mut local_ip: *const c_char = ptr::null();
let mut local_port = 0u16;
let mut peer_ip: *const c_char = ptr::null();
let mut peer_port = 0u16;
let handle = unsafe {
data_plane_tcp_accept_finish(
op as u64,
&mut local_ip,
&mut local_port,
&mut peer_ip,
&mut peer_port,
)
};
if handle == 0 {
throw_last(&mut env);
return ptr::null_mut();
}
let Some(local_addr) =
new_socket_addr(&mut env, unsafe { take_ffi_string(local_ip) }, local_port)
else {
free_string(peer_ip);
let _ = data_plane_tcp_close(handle);
return ptr::null_mut();
};
let Some(peer_addr) = new_socket_addr(&mut env, unsafe { take_ffi_string(peer_ip) }, peer_port)
else {
let _ = data_plane_tcp_close(handle);
return ptr::null_mut();
};
let class = match env.find_class(TCP_ACCEPT_RESULT_CLASS) {
Ok(class) => class,
Err(err) => {
throw_exception(
&mut env,
&format!("Failed to find accept result class: {:?}", err),
);
let _ = data_plane_tcp_close(handle);
return ptr::null_mut();
}
};
let sig = format!("(JL{};L{};)V", SOCKET_ADDR_CLASS, SOCKET_ADDR_CLASS);
let result = match env.new_object(
class,
sig.as_str(),
&[
JValue::Long(handle as jlong),
JValue::Object(&local_addr),
JValue::Object(&peer_addr),
],
) {
Ok(result) => result.into_raw(),
Err(err) => {
throw_exception(
&mut env,
&format!("Failed to create accept result: {:?}", err),
);
ptr::null_mut()
}
};
close_tcp_stream_on_null(result, handle)
}
pub(crate) fn tcp_read_start_jni(
mut env: JNIEnv,
_class: JClass,
handle: jlong,
max_len: jint,
timeout_ms: jlong,
) -> jlong {
let Some(max_len) = len_from_jint(&mut env, max_len, "max length") else {
return 0;
};
let op = unsafe {
data_plane_tcp_read_start(handle as u64, max_len, timeout_from_jlong(timeout_ms))
};
if op == 0 {
throw_last(&mut env);
}
op as jlong
}
pub(crate) fn tcp_read_finish_jni(mut env: JNIEnv, _class: JClass, op: jlong) -> jobject {
let mut ptr: *const u8 = ptr::null();
let mut len = 0u32;
let ret = unsafe { data_plane_tcp_read_finish(op as u64, &mut ptr, &mut len) };
if ret < 0 {
throw_last(&mut env);
return ptr::null_mut();
}
let bytes = read_owned_bytes(ptr, len);
let array = match env.byte_array_from_slice(&bytes) {
Ok(array) => array,
Err(err) => {
throw_exception(&mut env, &format!("Failed to create byte array: {:?}", err));
return ptr::null_mut();
}
};
let class = match env.find_class(TCP_READ_RESULT_CLASS) {
Ok(class) => class,
Err(err) => {
throw_exception(
&mut env,
&format!("Failed to find read result class: {:?}", err),
);
return ptr::null_mut();
}
};
match env.new_object(class, "([B)V", &[JValue::Object(&array)]) {
Ok(result) => result.into_raw(),
Err(err) => {
throw_exception(
&mut env,
&format!("Failed to create read result: {:?}", err),
);
ptr::null_mut()
}
}
}
pub(crate) fn tcp_write_start_jni(
mut env: JNIEnv,
_class: JClass,
handle: jlong,
data: JByteArray,
timeout_ms: jlong,
) -> jlong {
let data = match env.convert_byte_array(&data) {
Ok(data) => data,
Err(err) => {
throw_exception(&mut env, &format!("Invalid write buffer: {:?}", err));
return 0;
}
};
let ptr = if data.is_empty() {
ptr::null()
} else {
data.as_ptr()
};
let op = unsafe {
data_plane_tcp_write_start(
handle as u64,
ptr,
data.len() as u32,
timeout_from_jlong(timeout_ms),
)
};
if op == 0 {
throw_last(&mut env);
}
op as jlong
}
pub(crate) fn tcp_write_finish_jni(mut env: JNIEnv, _class: JClass, op: jlong) -> jint {
let ret = data_plane_tcp_write_finish(op as u64);
if ret < 0 {
throw_last(&mut env);
}
ret
}
pub(crate) fn udp_bind_start_jni(
mut env: JNIEnv,
_class: JClass,
inst_name: JString,
local_port: jint,
timeout_ms: jlong,
) -> jlong {
let inst_name = match jstring_to_cstring(&mut env, &inst_name) {
Ok(value) => value,
Err(err) => {
throw_exception(&mut env, &format!("Invalid instance name: {}", err));
return 0;
}
};
let Some(local_port) = port_from_jint(&mut env, local_port, "local port") else {
return 0;
};
let op = unsafe {
data_plane_udp_bind_start(
inst_name.as_ptr(),
local_port,
timeout_from_jlong(timeout_ms),
)
};
if op == 0 {
throw_last(&mut env);
}
op as jlong
}
pub(crate) fn udp_bind_finish_jni(mut env: JNIEnv, _class: JClass, op: jlong) -> jobject {
let mut ip: *const c_char = ptr::null();
let mut port = 0u16;
let handle = unsafe { data_plane_udp_bind_finish(op as u64, &mut ip, &mut port) };
if handle == 0 {
throw_last(&mut env);
return ptr::null_mut();
}
close_udp_socket_on_null(
new_handle_addr_result(
&mut env,
UDP_BIND_RESULT_CLASS,
handle,
unsafe { take_ffi_string(ip) },
port,
),
handle,
)
}
pub(crate) fn udp_send_to_start_jni(
mut env: JNIEnv,
_class: JClass,
handle: jlong,
dst_ip: JString,
dst_port: jint,
data: JByteArray,
timeout_ms: jlong,
) -> jlong {
let dst_ip = match jstring_to_cstring(&mut env, &dst_ip) {
Ok(value) => value,
Err(err) => {
throw_exception(&mut env, &format!("Invalid destination IP: {}", err));
return 0;
}
};
let Some(dst_port) = port_from_jint(&mut env, dst_port, "destination port") else {
return 0;
};
let data = match env.convert_byte_array(&data) {
Ok(data) => data,
Err(err) => {
throw_exception(&mut env, &format!("Invalid UDP send buffer: {:?}", err));
return 0;
}
};
let ptr = if data.is_empty() {
ptr::null()
} else {
data.as_ptr()
};
let op = unsafe {
data_plane_udp_send_to_start(
handle as u64,
dst_ip.as_ptr(),
dst_port,
ptr,
data.len() as u32,
timeout_from_jlong(timeout_ms),
)
};
if op == 0 {
throw_last(&mut env);
}
op as jlong
}
pub(crate) fn udp_send_to_finish_jni(mut env: JNIEnv, _class: JClass, op: jlong) -> jint {
let ret = data_plane_udp_send_to_finish(op as u64);
if ret < 0 {
throw_last(&mut env);
}
ret
}
pub(crate) fn udp_recv_from_start_jni(
mut env: JNIEnv,
_class: JClass,
handle: jlong,
max_len: jint,
timeout_ms: jlong,
) -> jlong {
let Some(max_len) = len_from_jint(&mut env, max_len, "max length") else {
return 0;
};
let op = unsafe {
data_plane_udp_recv_from_start(handle as u64, max_len, timeout_from_jlong(timeout_ms))
};
if op == 0 {
throw_last(&mut env);
}
op as jlong
}
pub(crate) fn udp_recv_from_finish_jni(mut env: JNIEnv, _class: JClass, op: jlong) -> jobject {
let mut ptr: *const u8 = ptr::null();
let mut len = 0u32;
let mut ip: *const c_char = ptr::null();
let mut port = 0u16;
let ret = unsafe {
data_plane_udp_recv_from_finish(op as u64, &mut ptr, &mut len, &mut ip, &mut port)
};
if ret < 0 {
throw_last(&mut env);
return ptr::null_mut();
}
let bytes = read_owned_bytes(ptr, len);
let array = match env.byte_array_from_slice(&bytes) {
Ok(array) => array,
Err(err) => {
free_string(ip);
throw_exception(&mut env, &format!("Failed to create byte array: {:?}", err));
return ptr::null_mut();
}
};
let Some(peer_addr) = new_socket_addr(&mut env, unsafe { take_ffi_string(ip) }, port) else {
return ptr::null_mut();
};
let class = match env.find_class(UDP_RECV_RESULT_CLASS) {
Ok(class) => class,
Err(err) => {
throw_exception(
&mut env,
&format!("Failed to find UDP recv result class: {:?}", err),
);
return ptr::null_mut();
}
};
let sig = format!("([BL{};)V", SOCKET_ADDR_CLASS);
match env.new_object(
class,
sig.as_str(),
&[JValue::Object(&array), JValue::Object(&peer_addr)],
) {
Ok(result) => result.into_raw(),
Err(err) => {
throw_exception(
&mut env,
&format!("Failed to create UDP recv result: {:?}", err),
);
ptr::null_mut()
}
}
}
pub(crate) fn tcp_close_jni(mut env: JNIEnv, _class: JClass, handle: jlong) -> jint {
let ret = data_plane_tcp_close(handle as u64);
if ret != 0 {
throw_last(&mut env);
}
ret
}
pub(crate) fn tcp_listener_close_jni(mut env: JNIEnv, _class: JClass, handle: jlong) -> jint {
let ret = data_plane_tcp_listener_close(handle as u64);
if ret != 0 {
throw_last(&mut env);
}
ret
}
pub(crate) fn udp_close_jni(mut env: JNIEnv, _class: JClass, handle: jlong) -> jint {
let ret = data_plane_udp_close(handle as u64);
if ret != 0 {
throw_last(&mut env);
}
ret
}
@@ -1,74 +0,0 @@
use std::{
ffi::{CStr, c_char},
ptr,
sync::Mutex,
};
use easytier_ffi::{free_string, get_error_msg};
use jni::JNIEnv;
use jni::objects::JClass;
use jni::sys::jstring;
use once_cell::sync::Lazy;
static JNI_CALLBACK_ERROR: Lazy<Mutex<Option<String>>> = Lazy::new(|| Mutex::new(None));
pub(crate) fn set_callback_error(error: String) {
log::error!("{}", error);
if let Ok(mut guard) = JNI_CALLBACK_ERROR.lock() {
*guard = Some(error);
}
}
pub(crate) fn clear_callback_error() {
if let Ok(mut guard) = JNI_CALLBACK_ERROR.lock() {
*guard = None;
}
}
fn take_callback_error() -> Option<String> {
JNI_CALLBACK_ERROR
.lock()
.ok()
.and_then(|mut guard| guard.take())
}
fn get_ffi_last_error() -> Option<String> {
unsafe {
let mut error_ptr: *const c_char = ptr::null();
get_error_msg(&mut error_ptr);
if error_ptr.is_null() {
None
} else {
let error_cstr = CStr::from_ptr(error_ptr);
let error_str = error_cstr.to_string_lossy().into_owned();
free_string(error_ptr);
Some(error_str)
}
}
}
pub(crate) fn get_last_error() -> Option<String> {
match (get_ffi_last_error(), take_callback_error()) {
(Some(ffi_error), Some(callback_error)) => Some(format!(
"{}; config server callback error: {}",
ffi_error, callback_error
)),
(Some(ffi_error), None) => Some(ffi_error),
(None, Some(callback_error)) => Some(callback_error),
(None, None) => None,
}
}
pub(crate) fn throw_exception(env: &mut JNIEnv, message: &str) {
let _ = env.throw_new("java/lang/RuntimeException", message);
}
pub(crate) fn get_last_error_jni(env: JNIEnv, _class: JClass) -> jstring {
match get_last_error() {
Some(error) => match env.new_string(&error) {
Ok(jstr) => jstr.into_raw(),
Err(_) => ptr::null_mut(),
},
None => ptr::null_mut(),
}
}
@@ -1,91 +0,0 @@
use std::{
ffi::{CStr, c_char},
ptr,
};
use easytier_ffi::{call_json_rpc, free_string};
use jni::JNIEnv;
use jni::objects::{JClass, JString};
use jni::sys::jstring;
use crate::{
error::{get_last_error, throw_exception},
strings::{jstring_to_cstring, optional_jstring_to_cstring},
};
pub(crate) fn call_json_rpc_jni(
mut env: JNIEnv,
_class: JClass,
service_name: JString,
method_name: JString,
domain_name: JString,
payload_json: JString,
) -> jstring {
let service_name_cstr = match jstring_to_cstring(&mut env, &service_name) {
Ok(cstr) => cstr,
Err(e) => {
throw_exception(&mut env, &format!("Invalid service name: {}", e));
return ptr::null_mut();
}
};
let method_name_cstr = match jstring_to_cstring(&mut env, &method_name) {
Ok(cstr) => cstr,
Err(e) => {
throw_exception(&mut env, &format!("Invalid method name: {}", e));
return ptr::null_mut();
}
};
let domain_name_cstr = match optional_jstring_to_cstring(&mut env, &domain_name) {
Ok(cstr) => cstr,
Err(e) => {
throw_exception(&mut env, &format!("Invalid domain name: {}", e));
return ptr::null_mut();
}
};
let payload_json_cstr = match jstring_to_cstring(&mut env, &payload_json) {
Ok(cstr) => cstr,
Err(e) => {
throw_exception(&mut env, &format!("Invalid payload JSON: {}", e));
return ptr::null_mut();
}
};
let domain_name_ptr = domain_name_cstr
.as_ref()
.map_or(ptr::null(), |cstr| cstr.as_ptr());
let mut response_ptr: *const c_char = ptr::null();
let result = unsafe {
call_json_rpc(
service_name_cstr.as_ptr(),
method_name_cstr.as_ptr(),
domain_name_ptr,
payload_json_cstr.as_ptr(),
&mut response_ptr,
)
};
if result != 0 {
if let Some(error) = get_last_error() {
throw_exception(&mut env, &error);
}
return ptr::null_mut();
}
if response_ptr.is_null() {
throw_exception(&mut env, "JSON RPC returned a null response");
return ptr::null_mut();
}
let response = unsafe { CStr::from_ptr(response_ptr) }
.to_string_lossy()
.into_owned();
free_string(response_ptr);
match env.new_string(&response) {
Ok(jstr) => jstr.into_raw(),
Err(_) => {
throw_exception(&mut env, "Failed to create JSON RPC response string");
ptr::null_mut()
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,13 +0,0 @@
use once_cell::sync::Lazy;
static LOGGER_INIT: Lazy<()> = Lazy::new(|| {
android_logger::init_once(
android_logger::Config::default()
.with_max_level(log::LevelFilter::Debug)
.with_tag("EasyTier-JNI"),
);
});
pub(crate) fn init() {
Lazy::force(&LOGGER_INIT);
}
@@ -1,261 +0,0 @@
use std::{ffi::CStr, ptr};
use easytier::proto::api::manage::{NetworkInstanceRunningInfo, NetworkInstanceRunningInfoMap};
use easytier_ffi::{
KeyValuePair, collect_network_infos, free_string, list_instance, parse_config,
retain_network_instance, run_network_instance, set_tun_fd,
};
use jni::JNIEnv;
use jni::objects::{JClass, JObjectArray, JString};
use jni::sys::{jint, jstring};
use crate::{
error::{get_last_error, throw_exception},
strings::jstring_to_cstring,
};
pub(crate) fn set_tun_fd_jni(
mut env: JNIEnv,
_class: JClass,
inst_name: JString,
fd: jint,
) -> jint {
let inst_name_cstr = match jstring_to_cstring(&mut env, &inst_name) {
Ok(cstr) => cstr,
Err(e) => {
throw_exception(&mut env, &format!("Invalid instance name: {}", e));
return -1;
}
};
unsafe {
let result = set_tun_fd(inst_name_cstr.as_ptr(), fd);
if result != 0
&& let Some(error) = get_last_error()
{
throw_exception(&mut env, &error);
}
result
}
}
pub(crate) fn parse_config_jni(mut env: JNIEnv, _class: JClass, config: JString) -> jint {
let config_cstr = match jstring_to_cstring(&mut env, &config) {
Ok(cstr) => cstr,
Err(e) => {
throw_exception(&mut env, &format!("Invalid config string: {}", e));
return -1;
}
};
unsafe {
let result = parse_config(config_cstr.as_ptr());
if result != 0
&& let Some(error) = get_last_error()
{
throw_exception(&mut env, &error);
}
result
}
}
pub(crate) fn run_network_instance_jni(mut env: JNIEnv, _class: JClass, config: JString) -> jint {
let config_cstr = match jstring_to_cstring(&mut env, &config) {
Ok(cstr) => cstr,
Err(e) => {
throw_exception(&mut env, &format!("Invalid config string: {}", e));
return -1;
}
};
unsafe {
let result = run_network_instance(config_cstr.as_ptr());
if result != 0
&& let Some(error) = get_last_error()
{
throw_exception(&mut env, &error);
}
result
}
}
pub(crate) fn retain_network_instance_jni(
mut env: JNIEnv,
_class: JClass,
instance_names: JObjectArray,
) -> jint {
if instance_names.is_null() {
return retain_all(&mut env);
}
let array_length = match env.get_array_length(&instance_names) {
Ok(len) => len as usize,
Err(e) => {
throw_exception(&mut env, &format!("Failed to get array length: {:?}", e));
return -1;
}
};
if array_length == 0 {
return retain_all(&mut env);
}
let mut c_strings = Vec::with_capacity(array_length);
let mut c_string_ptrs = Vec::with_capacity(array_length);
for i in 0..array_length {
let java_string = match env.get_object_array_element(&instance_names, i as i32) {
Ok(obj) => obj,
Err(e) => {
throw_exception(
&mut env,
&format!("Failed to get array element {}: {:?}", i, e),
);
return -1;
}
};
if java_string.is_null() {
throw_exception(
&mut env,
&format!("Invalid instance name at index {}: null", i),
);
return -1;
}
let jstring = JString::from(java_string);
let c_string = match jstring_to_cstring(&mut env, &jstring) {
Ok(cstr) => cstr,
Err(e) => {
throw_exception(
&mut env,
&format!("Invalid instance name at index {}: {}", i, e),
);
return -1;
}
};
c_string_ptrs.push(c_string.as_ptr());
c_strings.push(c_string);
}
unsafe {
let result = retain_network_instance(c_string_ptrs.as_ptr(), c_string_ptrs.len());
if result != 0
&& let Some(error) = get_last_error()
{
throw_exception(&mut env, &error);
}
result
}
}
fn retain_all(env: &mut JNIEnv) -> jint {
unsafe {
let result = retain_network_instance(ptr::null(), 0);
if result != 0
&& let Some(error) = get_last_error()
{
throw_exception(env, &error);
}
result
}
}
pub(crate) fn collect_network_infos_jni(
mut env: JNIEnv,
_class: JClass,
max_length: jint,
) -> jstring {
let max_length = max_length.max(0) as usize;
let mut infos = vec![
KeyValuePair {
key: ptr::null(),
value: ptr::null(),
};
max_length
];
unsafe {
let count = collect_network_infos(infos.as_mut_ptr(), max_length);
if count < 0 {
if let Some(error) = get_last_error() {
throw_exception(&mut env, &error);
}
return ptr::null_mut();
}
let mut ret = NetworkInstanceRunningInfoMap::default();
for info in infos.iter().take(count as usize) {
let key_ptr = info.key;
let val_ptr = info.value;
if key_ptr.is_null() || val_ptr.is_null() {
break;
}
let key = CStr::from_ptr(key_ptr).to_string_lossy().into_owned();
let val = CStr::from_ptr(val_ptr).to_string_lossy().into_owned();
free_string(key_ptr);
free_string(val_ptr);
let value = match serde_json::from_str::<NetworkInstanceRunningInfo>(&val) {
Ok(v) => v,
Err(_) => {
throw_exception(&mut env, "Failed to parse JSON");
continue;
}
};
ret.map.insert(key, value);
}
let json_str = serde_json::to_string(&ret).unwrap_or_else(|_| "{}".to_string());
match env.new_string(&json_str) {
Ok(jstr) => jstr.into_raw(),
Err(_) => {
throw_exception(&mut env, "Failed to create JSON string");
ptr::null_mut()
}
}
}
}
pub(crate) fn list_instances_jni(mut env: JNIEnv, _class: JClass, max_length: jint) -> jstring {
let max_length = max_length.max(0) as usize;
let mut infos = vec![
KeyValuePair {
key: ptr::null(),
value: ptr::null(),
};
max_length
];
unsafe {
let count = list_instance(infos.as_mut_ptr(), max_length);
if count < 0 {
if let Some(error) = get_last_error() {
throw_exception(&mut env, &error);
}
return ptr::null_mut();
}
let mut ret = serde_json::Map::new();
for info in infos.iter().take(count as usize) {
let key_ptr = info.key;
let val_ptr = info.value;
if key_ptr.is_null() || val_ptr.is_null() {
break;
}
let key = CStr::from_ptr(key_ptr).to_string_lossy().into_owned();
let val = CStr::from_ptr(val_ptr).to_string_lossy().into_owned();
free_string(key_ptr);
free_string(val_ptr);
ret.insert(key, serde_json::Value::String(val));
}
let json_str = serde_json::Value::Object(ret).to_string();
match env.new_string(&json_str) {
Ok(jstr) => jstr.into_raw(),
Err(_) => {
throw_exception(&mut env, "Failed to create instance list JSON string");
ptr::null_mut()
}
}
}
}
@@ -1,23 +0,0 @@
use std::ffi::CString;
use jni::JNIEnv;
use jni::objects::JString;
pub(crate) fn jstring_to_cstring(env: &mut JNIEnv, jstr: &JString) -> Result<CString, String> {
let java_str = env
.get_string(jstr)
.map_err(|e| format!("Failed to get string: {:?}", e))?;
let rust_str = java_str.to_str().map_err(|_| "Invalid UTF-8".to_string())?;
CString::new(rust_str).map_err(|_| "String contains null byte".to_string())
}
pub(crate) fn optional_jstring_to_cstring(
env: &mut JNIEnv,
jstr: &JString,
) -> Result<Option<CString>, String> {
if jstr.is_null() {
return Ok(None);
}
jstring_to_cstring(env, jstr).map(Some)
}
+1 -12
View File
@@ -4,25 +4,14 @@ version = "0.1.0"
edition.workspace = true
[lib]
crate-type = ["cdylib", "rlib"]
[features]
default = ["c-abi", "ffi-dataplane"]
c-abi = []
ffi-dataplane = ["easytier/ffi-dataplane"]
crate-type = ["cdylib"]
[dependencies]
easytier = { path = "../../easytier" }
once_cell = "1.18.0"
dashmap = "6.0"
tokio = { version = "1", features = ["rt-multi-thread", "io-util", "time", "sync", "macros"] }
async-trait = "0.1"
log = "0.4"
percent-encoding = "2.3"
url = "2"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1"
uuid = "1.17.0"
tokio-util = "0.7"
@@ -1,429 +0,0 @@
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define DATA_PLANE_OP_PENDING 0
#define DATA_PLANE_OP_READY 1
#define DATA_PLANE_OP_FAILED -1
#define DATA_PLANE_OP_INVALID -2
extern int run_network_instance(const char *cfg_str);
extern void get_error_msg(const char **out);
extern void free_string(const char *s);
extern int data_plane_async_op_status(uint64_t op);
extern int data_plane_async_op_wait(uint64_t op, uint64_t timeout_ms);
extern int data_plane_async_op_cancel(uint64_t op);
extern int data_plane_async_op_free(uint64_t op);
extern void data_plane_free_bytes(const uint8_t *ptr, uint32_t len);
extern uint64_t data_plane_tcp_connect_start(
const char *inst_name,
const char *dst_ip,
uint16_t dst_port,
uint64_t timeout_ms);
extern uint64_t data_plane_tcp_connect_finish(
uint64_t op,
const char **out_local_ip,
uint16_t *out_local_port);
extern uint64_t data_plane_tcp_bind_start(
const char *inst_name,
uint16_t local_port,
uint64_t timeout_ms);
extern uint64_t data_plane_tcp_bind_finish(
uint64_t op,
const char **out_local_ip,
uint16_t *out_local_port);
extern uint64_t data_plane_tcp_accept_start(uint64_t listener, uint64_t timeout_ms);
extern uint64_t data_plane_tcp_accept_finish(
uint64_t op,
const char **out_local_ip,
uint16_t *out_local_port,
const char **out_peer_ip,
uint16_t *out_peer_port);
extern uint64_t data_plane_tcp_read_start(
uint64_t stream,
uint32_t max_len,
uint64_t timeout_ms);
extern int data_plane_tcp_read_finish(
uint64_t op,
const uint8_t **out_buf,
uint32_t *out_len);
extern uint64_t data_plane_tcp_write_start(
uint64_t stream,
const uint8_t *buf,
uint32_t len,
uint64_t timeout_ms);
extern int data_plane_tcp_write_finish(uint64_t op);
extern int data_plane_tcp_close(uint64_t stream);
extern int data_plane_tcp_listener_close(uint64_t listener);
extern uint64_t data_plane_udp_bind_start(
const char *inst_name,
uint16_t local_port,
uint64_t timeout_ms);
extern uint64_t data_plane_udp_bind_finish(
uint64_t op,
const char **out_local_ip,
uint16_t *out_local_port);
extern uint64_t data_plane_udp_send_to_start(
uint64_t socket,
const char *dst_ip,
uint16_t dst_port,
const uint8_t *buf,
uint32_t len,
uint64_t timeout_ms);
extern int data_plane_udp_send_to_finish(uint64_t op);
extern uint64_t data_plane_udp_recv_from_start(
uint64_t socket,
uint32_t max_len,
uint64_t timeout_ms);
extern int data_plane_udp_recv_from_finish(
uint64_t op,
const uint8_t **out_buf,
uint32_t *out_len,
const char **out_ip,
uint16_t *out_port);
extern int data_plane_udp_close(uint64_t socket);
static void print_last_error(const char *prefix) {
const char *err = NULL;
get_error_msg(&err);
if (err) {
fprintf(stderr, "%s: %s\n", prefix, err);
free_string(err);
} else {
fprintf(stderr, "%s\n", prefix);
}
}
static int parse_ip_port(const char *value, char *ip, size_t ip_len, uint16_t *port) {
const char *colon = strrchr(value, ':');
if (!colon || colon == value || !colon[1]) {
fprintf(stderr, "expected IPv4 target in IP:PORT form, got %s\n", value);
return -1;
}
size_t host_len = (size_t)(colon - value);
if (host_len >= ip_len) {
fprintf(stderr, "IP address is too long: %s\n", value);
return -1;
}
char *end = NULL;
long parsed_port = strtol(colon + 1, &end, 10);
if (!end || *end != '\0' || parsed_port < 0 || parsed_port > 65535) {
fprintf(stderr, "invalid port in %s\n", value);
return -1;
}
memcpy(ip, value, host_len);
ip[host_len] = '\0';
*port = (uint16_t)parsed_port;
return 0;
}
static int wait_op(uint64_t op, uint64_t timeout_ms) {
uint64_t waited = 0;
while (waited < timeout_ms) {
int status = data_plane_async_op_wait(op, 50);
if (status != DATA_PLANE_OP_PENDING) {
return status;
}
waited += 50;
}
return data_plane_async_op_status(op);
}
static int wait_or_cancel(uint64_t op, uint64_t timeout_ms, const char *what) {
int status = wait_op(op, timeout_ms);
if (status == DATA_PLANE_OP_READY || status == DATA_PLANE_OP_FAILED) {
return status;
}
if (status == DATA_PLANE_OP_PENDING) {
fprintf(stderr, "%s did not finish within %llu ms\n", what, (unsigned long long)timeout_ms);
data_plane_async_op_cancel(op);
data_plane_async_op_free(op);
return DATA_PLANE_OP_INVALID;
}
fprintf(stderr, "%s returned invalid op status %d\n", what, status);
return status;
}
static int async_tcp_read_once(uint64_t stream, uint64_t timeout_ms) {
uint64_t op = data_plane_tcp_read_start(stream, 512, timeout_ms);
if (!op) {
print_last_error("tcp read start failed");
return -1;
}
if (wait_or_cancel(op, timeout_ms + 1000, "tcp read") == DATA_PLANE_OP_INVALID) {
return -1;
}
const uint8_t *buf = NULL;
uint32_t len = 0;
int ret = data_plane_tcp_read_finish(op, &buf, &len);
if (ret < 0) {
print_last_error("tcp read finish failed");
return -1;
}
printf("tcp read %d bytes: %.*s\n", ret, ret, buf ? (const char *)buf : "");
data_plane_free_bytes(buf, len);
return 0;
}
static int async_tcp_write_all(uint64_t stream, const char *data, uint64_t timeout_ms) {
uint64_t op = data_plane_tcp_write_start(
stream,
(const uint8_t *)data,
(uint32_t)strlen(data),
timeout_ms);
if (!op) {
print_last_error("tcp write start failed");
return -1;
}
if (wait_or_cancel(op, timeout_ms + 1000, "tcp write") == DATA_PLANE_OP_INVALID) {
return -1;
}
int ret = data_plane_tcp_write_finish(op);
if (ret < 0) {
print_last_error("tcp write finish failed");
return -1;
}
printf("tcp wrote %d bytes\n", ret);
return 0;
}
static int run_tcp_connect_demo(const char *inst, const char *target) {
char ip[128];
uint16_t port = 0;
if (parse_ip_port(target, ip, sizeof(ip), &port) != 0) {
return -1;
}
uint64_t op = data_plane_tcp_connect_start(inst, ip, port, 30000);
if (!op) {
print_last_error("tcp connect start failed");
return -1;
}
if (wait_or_cancel(op, 31000, "tcp connect") == DATA_PLANE_OP_INVALID) {
return -1;
}
const char *local_ip = NULL;
uint16_t local_port = 0;
uint64_t stream = data_plane_tcp_connect_finish(op, &local_ip, &local_port);
if (!stream) {
print_last_error("tcp connect finish failed");
return -1;
}
printf("tcp connected from %s:%u to %s:%u, handle=%llu\n",
local_ip,
local_port,
ip,
port,
(unsigned long long)stream);
free_string(local_ip);
int ret = async_tcp_read_once(stream, 10000);
data_plane_tcp_close(stream);
return ret;
}
static int run_tcp_listen_demo(const char *inst, const char *port_text) {
uint16_t port = (uint16_t)strtoul(port_text, NULL, 10);
uint64_t op = data_plane_tcp_bind_start(inst, port, 30000);
if (!op) {
print_last_error("tcp bind start failed");
return -1;
}
if (wait_or_cancel(op, 31000, "tcp bind") == DATA_PLANE_OP_INVALID) {
return -1;
}
const char *local_ip = NULL;
uint16_t local_port = 0;
uint64_t listener = data_plane_tcp_bind_finish(op, &local_ip, &local_port);
if (!listener) {
print_last_error("tcp bind finish failed");
return -1;
}
printf("tcp listening on %s:%u, handle=%llu\n",
local_ip,
local_port,
(unsigned long long)listener);
free_string(local_ip);
op = data_plane_tcp_accept_start(listener, 60000);
if (!op) {
print_last_error("tcp accept start failed");
data_plane_tcp_listener_close(listener);
return -1;
}
if (wait_or_cancel(op, 61000, "tcp accept") == DATA_PLANE_OP_INVALID) {
data_plane_tcp_listener_close(listener);
return -1;
}
const char *peer_ip = NULL;
uint16_t peer_port = 0;
local_ip = NULL;
local_port = 0;
uint64_t stream = data_plane_tcp_accept_finish(
op,
&local_ip,
&local_port,
&peer_ip,
&peer_port);
data_plane_tcp_listener_close(listener);
if (!stream) {
print_last_error("tcp accept finish failed");
return -1;
}
printf("tcp accepted %s:%u -> %s:%u, stream=%llu\n",
peer_ip,
peer_port,
local_ip,
local_port,
(unsigned long long)stream);
free_string(local_ip);
free_string(peer_ip);
int ret = async_tcp_read_once(stream, 10000);
if (ret == 0) {
ret = async_tcp_write_all(stream, "pong", 10000);
}
data_plane_tcp_close(stream);
return ret;
}
static int run_udp_demo(const char *inst, const char *target) {
char ip[128];
uint16_t port = 0;
if (parse_ip_port(target, ip, sizeof(ip), &port) != 0) {
return -1;
}
uint64_t op = data_plane_udp_bind_start(inst, 0, 30000);
if (!op) {
print_last_error("udp bind start failed");
return -1;
}
if (wait_or_cancel(op, 31000, "udp bind") == DATA_PLANE_OP_INVALID) {
return -1;
}
const char *local_ip = NULL;
uint16_t local_port = 0;
uint64_t socket = data_plane_udp_bind_finish(op, &local_ip, &local_port);
if (!socket) {
print_last_error("udp bind finish failed");
return -1;
}
printf("udp bound on %s:%u, handle=%llu\n",
local_ip,
local_port,
(unsigned long long)socket);
free_string(local_ip);
const char payload[] = "ping";
op = data_plane_udp_send_to_start(
socket,
ip,
port,
(const uint8_t *)payload,
(uint32_t)strlen(payload),
10000);
if (!op) {
print_last_error("udp send start failed");
data_plane_udp_close(socket);
return -1;
}
if (wait_or_cancel(op, 11000, "udp send") == DATA_PLANE_OP_INVALID) {
data_plane_udp_close(socket);
return -1;
}
int sent = data_plane_udp_send_to_finish(op);
if (sent < 0) {
print_last_error("udp send finish failed");
data_plane_udp_close(socket);
return -1;
}
printf("udp sent %d bytes to %s:%u\n", sent, ip, port);
op = data_plane_udp_recv_from_start(socket, 512, 30000);
if (!op) {
print_last_error("udp recv start failed");
data_plane_udp_close(socket);
return -1;
}
if (wait_or_cancel(op, 31000, "udp recv") == DATA_PLANE_OP_INVALID) {
data_plane_udp_close(socket);
return -1;
}
const uint8_t *buf = NULL;
uint32_t len = 0;
const char *peer_ip = NULL;
uint16_t peer_port = 0;
int ret = data_plane_udp_recv_from_finish(op, &buf, &len, &peer_ip, &peer_port);
if (ret < 0) {
print_last_error("udp recv finish failed");
data_plane_udp_close(socket);
return -1;
}
printf("udp received %d bytes from %s:%u: %.*s\n",
ret,
peer_ip,
peer_port,
ret,
buf ? (const char *)buf : "");
data_plane_free_bytes(buf, len);
free_string(peer_ip);
data_plane_udp_close(socket);
return 0;
}
static void print_usage(void) {
printf("Set EASYTIER_FFI_CONFIG and EASYTIER_FFI_INSTANCE to run the async data-plane demo.\n");
printf("Optional demos:\n");
printf(" EASYTIER_FFI_TARGET=10.0.0.2:22 async TCP connect/read\n");
printf(" EASYTIER_FFI_LISTEN_PORT=12345 async TCP bind/accept/read/write\n");
printf(" EASYTIER_FFI_UDP_TARGET=10.0.0.2:9000 async UDP bind/send_to/recv_from\n");
}
int main(void) {
const char *config = getenv("EASYTIER_FFI_CONFIG");
const char *instance = getenv("EASYTIER_FFI_INSTANCE");
if (!config || !instance) {
print_usage();
return 0;
}
if (run_network_instance(config) != 0) {
print_last_error("run_network_instance failed");
return 1;
}
printf("network instance started: %s\n", instance);
int failed = 0;
const char *target = getenv("EASYTIER_FFI_TARGET");
if (target) {
failed |= run_tcp_connect_demo(instance, target) != 0;
}
const char *listen_port = getenv("EASYTIER_FFI_LISTEN_PORT");
if (listen_port) {
failed |= run_tcp_listen_demo(instance, listen_port) != 0;
}
const char *udp_target = getenv("EASYTIER_FFI_UDP_TARGET");
if (udp_target) {
failed |= run_udp_demo(instance, udp_target) != 0;
}
if (!target && !listen_port && !udp_target) {
printf("No dataplane demo env var was set; nothing else to run.\n");
print_usage();
}
return failed ? 1 : 0;
}
@@ -1,100 +0,0 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
#include <unistd.h> // for sleep
// FFI struct and function declarations
typedef struct {
const char* key;
const char* value;
} KeyValuePair;
typedef void (*config_server_event_callback)(
const char* event_json,
void* user_data
);
extern int parse_config(const char* cfg_str);
extern int run_network_instance(const char* cfg_str);
extern void get_error_msg(const char** out);
extern void free_string(const char* s);
extern int collect_network_infos(KeyValuePair* infos, size_t max_length);
extern int start_config_server_client(
const char* config_server_url,
const char* hostname,
const char* machine_id,
bool secure_mode,
config_server_event_callback callback,
void* user_data
);
extern int stop_config_server_client(void);
extern int is_config_server_client_connected(void);
static void on_config_server_event(const char* event_json, void* user_data) {
(void)user_data;
printf("config server event: %s\n", event_json);
}
int main() {
const char* config = "inst_name = \"test\"\nnetwork = \"test_network\"\n";
int ret;
// 调用 parse_config
ret = parse_config(config);
if (ret != 0) {
const char* err = NULL;
get_error_msg(&err);
if (err) {
printf("parse_config error: %s\n", err);
free_string(err);
}
return 1;
}
printf("parse_config success\n");
// 调用 run_network_instance
ret = run_network_instance(config);
if (ret != 0) {
const char* err = NULL;
get_error_msg(&err);
if (err) {
printf("run_network_instance error: %s\n", err);
free_string(err);
}
return 1;
}
printf("run_network_instance success\n");
// 周期性调用 collect_network_infos 并打印
const size_t max_infos = 8;
KeyValuePair* infos = (KeyValuePair*)malloc(sizeof(KeyValuePair) * max_infos);
if (!infos) {
fprintf(stderr, "malloc failed\n");
return 1;
}
for (int i = 0; i < 5; ++i) { // 循环5次作为示例
memset(infos, 0, sizeof(KeyValuePair) * max_infos);
int count = collect_network_infos(infos, max_infos);
if (count < 0) {
const char* err = NULL;
get_error_msg(&err);
if (err) {
printf("collect_network_infos error: %s\n", err);
free_string(err);
}
break;
}
printf("collect_network_infos: %d instance(s)\n", count);
for (int j = 0; j < count; ++j) {
printf(" [%d] key: %s\n value: %s\n", j, infos[j].key, infos[j].value);
free_string(infos[j].key);
free_string(infos[j].value);
}
sleep(1);
}
free(infos);
return 0;
}
@@ -1,138 +0,0 @@
# 1. Go FFI Demo
This demo wraps EasyTier FFI data-plane TCP as Go `net.Conn` and `net.Listener`.
It can connect to an SSH server through EasyTier and read its banner, or accept a
TCP connection from another EasyTier peer and run a small ping/pong exchange.
The async op-handle wrapper is in `easytier_async.go`; the original synchronous
wrapper stays in `easytier.go`.
## 1.1. Build the FFI library
Run from the repository root:
```sh
cargo build -p easytier-ffi --features ffi-dataplane
```
The demo loads the debug library by default:
```text
target/debug/libeasytier_ffi.so
```
To use another library path, export `EASYTIER_FFI_LIB=/path/to/libeasytier_ffi.so`.
## 1.2. Configure the EasyTier config
`EASYTIER_FFI_CONFIG` is a string of the EasyTier config in TOML format which is passed to the FFI library. For example:
```sh
export EASYTIER_FFI_CONFIG='instance_name = "default"
ipv4 = "10.0.0.1"
[network_identity]
network_name = "testnet"
network_secret = "mysecret"
[flags]
no_tun = true # disable tun device to avoid permission issues.
bind_device = false # allow loopback peers in local examples.
[[peer]]
uri = "tcp://123.123.123.123:11010"
'
```
You should configure with your own real values.
Set the local instance name and a SSH server target to connect through EasyTier:
```sh
export EASYTIER_FFI_INSTANCE=default
export EASYTIER_FFI_TARGET=10.0.0.2:22
```
To run the TCP listen integration test in the same `go test` process as the SSH
test, use a separate instance name and config:
```sh
export EASYTIER_FFI_LISTEN_CONFIG='instance_name = "listener"
ipv4 = "10.0.0.3"
[network_identity]
network_name = "testnet"
network_secret = "mysecret"
[flags]
no_tun = true
bind_device = false
[[peer]]
uri = "tcp://123.123.123.123:11010"
'
export EASYTIER_FFI_LISTEN_INSTANCE=listener
export EASYTIER_FFI_LISTEN_PORT=12345
```
## 1.3. Run the demo
`goffi` is built without cgo on Linux, so run the tests with `CGO_ENABLED=0`:
```sh
cd easytier-contrib/easytier-ffi/examples/go
CGO_ENABLED=0 go test -v ./...
```
The synchronous tests use the environment variables above. The async Go tests
are self-contained: they start two local EasyTier instances in the same test
process with `no_tun = true` and `bind_device = false`, then run TCP and UDP
ping/pong over the async data-plane API.
The synchronous wrapper also exposes `CallJSONRPC(service, method, domain,
payload)` for non-lifecycle EasyTier RPCs. For example,
`CallJSONRPC("api.logger.LoggerRpcService", "get_logger_config", "", "{}")`
returns the logger config as protobuf JSON. Instance lifecycle management RPCs
are intentionally filtered; use the dedicated FFI APIs for starting and
stopping instances.
To run only the async tests:
```sh
cd easytier-contrib/easytier-ffi/examples/go
CGO_ENABLED=0 go test -run 'TestAsync' -v ./...
```
When the SSH integration environment variables are set, expected synchronous
test output includes an SSH banner similar to:
```text
attempt 1: got banner "SSH-2.0-..."
PASS
```
For `TestTCPListenIntegration`, connect from another EasyTier peer to the local
EasyTier IPv4 address and `EASYTIER_FFI_LISTEN_PORT`, send `ping`, and expect
`pong` in response.
The async test output should include local TCP bind/connect log lines and finish
with `PASS` without any extra environment variables.
## 1.4. C async example
The C async example is kept separate from the basic C example:
```sh
cargo build -p easytier-ffi --features ffi-dataplane
cc -Wall -Wextra -pedantic \
../example_data_plane_async.c \
-L ../../../../target/debug -leasytier_ffi \
-Wl,-rpath,../../../../target/debug \
-o /tmp/easytier_data_plane_async
/tmp/easytier_data_plane_async
```
Without environment variables it prints usage and exits successfully. With
`EASYTIER_FFI_CONFIG`, `EASYTIER_FFI_INSTANCE`, and one of
`EASYTIER_FFI_TARGET`, `EASYTIER_FFI_LISTEN_PORT`, or `EASYTIER_FFI_UDP_TARGET`,
it runs the corresponding async data-plane flow.
@@ -1,593 +0,0 @@
package easytierffi
import (
"context"
"errors"
"fmt"
"io"
"net"
"os"
"runtime"
"strconv"
"strings"
"sync/atomic"
"time"
"unsafe"
"github.com/go-webgpu/goffi/ffi"
"github.com/go-webgpu/goffi/types"
)
const defaultTimeout = 30 * time.Second
type Native struct {
lib unsafe.Pointer
runNetworkInstance symCall
callJSONRPC symCall
getErrorMsg symCall
freeString symCall
tcpConnect symCall
tcpBind symCall
tcpAccept symCall
tcpRead symCall
tcpWrite symCall
tcpClose symCall
tcpListenerClose symCall
}
type Conn struct {
native *Native
handle uint64
local net.Addr
remote net.Addr
closed atomic.Bool
rd atomicDeadline
wd atomicDeadline
}
type Listener struct {
native *Native
handle uint64
addr net.Addr
closed atomic.Bool
}
type symCall struct {
fn unsafe.Pointer
cif types.CallInterface
}
type atomicDeadline struct{ v atomic.Int64 }
type timeoutError string
func Open(path string) (*Native, error) {
lib, err := ffi.LoadLibrary(path)
if err != nil {
return nil, err
}
n := &Native{lib: lib}
if err := n.bind(); err != nil {
ffi.FreeLibrary(lib)
return nil, err
}
return n, nil
}
func (n *Native) Close() error {
if n.lib == nil {
return nil
}
ffi.FreeLibrary(n.lib)
n.lib = nil
return nil
}
func (n *Native) RunNetworkInstance(config string) error {
defer pinErrorThread()()
cfg := cString(config)
cfgPtr := unsafe.Pointer(&cfg[0])
var ret int32
err := n.runNetworkInstance.call(unsafe.Pointer(&ret), unsafe.Pointer(&cfgPtr))
runtime.KeepAlive(cfg)
if err != nil {
return err
}
if ret != 0 {
return n.lastError()
}
return nil
}
func (n *Native) CallJSONRPC(serviceName, methodName, domainName, payloadJSON string) (string, error) {
defer pinErrorThread()()
service := cString(serviceName)
method := cString(methodName)
payload := cString(payloadJSON)
servicePtr := unsafe.Pointer(&service[0])
methodPtr := unsafe.Pointer(&method[0])
payloadPtr := unsafe.Pointer(&payload[0])
var domain []byte
var domainPtr unsafe.Pointer
if domainName != "" {
domain = cString(domainName)
domainPtr = unsafe.Pointer(&domain[0])
}
var response unsafe.Pointer
responseArg := unsafe.Pointer(&response)
var ret int32
err := n.callJSONRPC.call(
unsafe.Pointer(&ret),
unsafe.Pointer(&servicePtr),
unsafe.Pointer(&methodPtr),
unsafe.Pointer(&domainPtr),
unsafe.Pointer(&payloadPtr),
unsafe.Pointer(&responseArg),
)
runtime.KeepAlive(service)
runtime.KeepAlive(method)
runtime.KeepAlive(domain)
runtime.KeepAlive(payload)
if err != nil {
return "", err
}
if ret != 0 {
return "", n.lastError()
}
if response == nil {
return "", errors.New("easytier ffi JSON RPC returned nil response")
}
defer func() { _ = n.freeCString(response) }()
return readCString(response), nil
}
func (n *Native) DialContext(ctx context.Context, instance, network, address string) (net.Conn, error) {
if network != "tcp" && network != "tcp4" && network != "tcp6" {
return nil, net.UnknownNetworkError(network)
}
ip, port, err := parseIPPort(address)
if err != nil {
return nil, err
}
timeout := defaultTimeout
if deadline, ok := ctx.Deadline(); ok {
timeout = time.Until(deadline)
}
if timeout <= 0 {
return nil, context.DeadlineExceeded
}
if err := ctx.Err(); err != nil {
return nil, err
}
handle, local, err := n.tcpConnectTo(instance, ip.String(), uint16(port), timeout)
if err != nil {
return nil, err
}
return &Conn{native: n, handle: handle, local: local, remote: &net.TCPAddr{IP: ip, Port: port}}, nil
}
func (n *Native) ListenContext(ctx context.Context, instance, network, address string) (net.Listener, error) {
if network != "tcp" && network != "tcp4" && network != "tcp6" {
return nil, net.UnknownNetworkError(network)
}
port, err := parseListenPort(address)
if err != nil {
return nil, err
}
timeout := defaultTimeout
if deadline, ok := ctx.Deadline(); ok {
timeout = time.Until(deadline)
}
if timeout <= 0 {
return nil, context.DeadlineExceeded
}
if err := ctx.Err(); err != nil {
return nil, err
}
handle, local, err := n.tcpBindTo(instance, uint16(port), timeout)
if err != nil {
return nil, err
}
return &Listener{native: n, handle: handle, addr: local}, nil
}
func (c *Conn) Read(b []byte) (int, error) {
if c.closed.Load() {
return 0, net.ErrClosed
}
n, err := c.native.tcpReadFrom(c.handle, b, c.rd.timeout(defaultTimeout))
if err != nil {
return 0, opError("read", c.remote, err)
}
if n == 0 {
return 0, io.EOF
}
return n, nil
}
func (c *Conn) Write(b []byte) (int, error) {
if c.closed.Load() {
return 0, net.ErrClosed
}
n, err := c.native.tcpWriteTo(c.handle, b, c.wd.timeout(defaultTimeout))
if err != nil {
return 0, opError("write", c.remote, err)
}
return n, nil
}
func (c *Conn) Close() error {
if !c.closed.CompareAndSwap(false, true) {
return net.ErrClosed
}
return c.native.tcpCloseHandle(c.handle)
}
func (c *Conn) LocalAddr() net.Addr { return c.local }
func (c *Conn) RemoteAddr() net.Addr { return c.remote }
func (c *Conn) SetDeadline(t time.Time) error { c.rd.set(t); c.wd.set(t); return nil }
func (c *Conn) SetReadDeadline(t time.Time) error { c.rd.set(t); return nil }
func (c *Conn) SetWriteDeadline(t time.Time) error { c.wd.set(t); return nil }
func (l *Listener) Accept() (net.Conn, error) {
if l.closed.Load() {
return nil, net.ErrClosed
}
for {
handle, local, peer, err := l.native.tcpAcceptFrom(l.handle, defaultTimeout)
if err == nil {
return &Conn{native: l.native, handle: handle, local: local, remote: peer}, nil
}
if l.closed.Load() {
return nil, net.ErrClosed
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
continue
}
return nil, opError("accept", l.addr, err)
}
}
func (l *Listener) Close() error {
if !l.closed.CompareAndSwap(false, true) {
return net.ErrClosed
}
return l.native.tcpListenerCloseHandle(l.handle)
}
func (l *Listener) Addr() net.Addr { return l.addr }
func (n *Native) bind() error {
return errors.Join(
n.bindSym(&n.runNetworkInstance, "run_network_instance", types.SInt32TypeDescriptor, types.PointerTypeDescriptor),
n.bindSym(&n.callJSONRPC, "call_json_rpc", types.SInt32TypeDescriptor, types.PointerTypeDescriptor, types.PointerTypeDescriptor, types.PointerTypeDescriptor, types.PointerTypeDescriptor, types.PointerTypeDescriptor),
n.bindSym(&n.getErrorMsg, "get_error_msg", types.VoidTypeDescriptor, types.PointerTypeDescriptor),
n.bindSym(&n.freeString, "free_string", types.VoidTypeDescriptor, types.PointerTypeDescriptor),
n.bindSym(&n.tcpConnect, "data_plane_tcp_connect", types.UInt64TypeDescriptor, types.PointerTypeDescriptor, types.PointerTypeDescriptor, types.UInt16TypeDescriptor, types.UInt64TypeDescriptor, types.PointerTypeDescriptor, types.PointerTypeDescriptor),
n.bindSym(&n.tcpBind, "data_plane_tcp_bind", types.UInt64TypeDescriptor, types.PointerTypeDescriptor, types.UInt16TypeDescriptor, types.UInt64TypeDescriptor, types.PointerTypeDescriptor, types.PointerTypeDescriptor),
n.bindSym(&n.tcpAccept, "data_plane_tcp_accept", types.UInt64TypeDescriptor, types.UInt64TypeDescriptor, types.UInt64TypeDescriptor, types.PointerTypeDescriptor, types.PointerTypeDescriptor, types.PointerTypeDescriptor, types.PointerTypeDescriptor),
n.bindSym(&n.tcpRead, "data_plane_tcp_read", types.SInt32TypeDescriptor, types.UInt64TypeDescriptor, types.PointerTypeDescriptor, types.UInt32TypeDescriptor, types.UInt64TypeDescriptor),
n.bindSym(&n.tcpWrite, "data_plane_tcp_write", types.SInt32TypeDescriptor, types.UInt64TypeDescriptor, types.PointerTypeDescriptor, types.UInt32TypeDescriptor, types.UInt64TypeDescriptor),
n.bindSym(&n.tcpClose, "data_plane_tcp_close", types.SInt32TypeDescriptor, types.UInt64TypeDescriptor),
n.bindSym(&n.tcpListenerClose, "data_plane_tcp_listener_close", types.SInt32TypeDescriptor, types.UInt64TypeDescriptor),
)
}
func (n *Native) bindSym(dst *symCall, name string, ret *types.TypeDescriptor, args ...*types.TypeDescriptor) error {
sym, err := ffi.GetSymbol(n.lib, name)
if err != nil {
return err
}
if err := ffi.PrepareCallInterface(&dst.cif, types.DefaultCall, ret, args); err != nil {
return err
}
dst.fn = sym
return nil
}
func (s *symCall) call(ret unsafe.Pointer, args ...unsafe.Pointer) error {
// `ffi.CallFunction` and libffi `ffi_call` are safe to invoke concurrently
// because `cif` is prepared once during binding and only read afterwards.
return ffi.CallFunction(&s.cif, s.fn, ret, args)
}
func (n *Native) tcpConnectTo(instance, ip string, port uint16, timeout time.Duration) (uint64, *net.TCPAddr, error) {
defer pinErrorThread()()
inst := cString(instance)
dst := cString(ip)
instPtr := unsafe.Pointer(&inst[0])
dstPtr := unsafe.Pointer(&dst[0])
timeoutMS := uint64(timeout / time.Millisecond)
var handle uint64
var outIP unsafe.Pointer
outIPArg := unsafe.Pointer(&outIP)
var outPort uint16
outPortArg := unsafe.Pointer(&outPort)
err := n.tcpConnect.call(
unsafe.Pointer(&handle),
unsafe.Pointer(&instPtr),
unsafe.Pointer(&dstPtr),
unsafe.Pointer(&port),
unsafe.Pointer(&timeoutMS),
unsafe.Pointer(&outIPArg),
unsafe.Pointer(&outPortArg),
)
runtime.KeepAlive(inst)
runtime.KeepAlive(dst)
if err != nil {
return 0, nil, err
}
if handle == 0 {
return 0, nil, n.lastError()
}
return handle, n.takeTCPAddr(outIP, outPort), nil
}
func (n *Native) tcpBindTo(instance string, port uint16, timeout time.Duration) (uint64, *net.TCPAddr, error) {
defer pinErrorThread()()
inst := cString(instance)
instPtr := unsafe.Pointer(&inst[0])
timeoutMS := uint64(timeout / time.Millisecond)
var handle uint64
var outIP unsafe.Pointer
outIPArg := unsafe.Pointer(&outIP)
var outPort uint16
outPortArg := unsafe.Pointer(&outPort)
err := n.tcpBind.call(
unsafe.Pointer(&handle),
unsafe.Pointer(&instPtr),
unsafe.Pointer(&port),
unsafe.Pointer(&timeoutMS),
unsafe.Pointer(&outIPArg),
unsafe.Pointer(&outPortArg),
)
runtime.KeepAlive(inst)
if err != nil {
return 0, nil, err
}
if handle == 0 {
return 0, nil, n.lastError()
}
return handle, n.takeTCPAddr(outIP, outPort), nil
}
func (n *Native) tcpAcceptFrom(handle uint64, timeout time.Duration) (uint64, *net.TCPAddr, *net.TCPAddr, error) {
defer pinErrorThread()()
timeoutMS := uint64(timeout / time.Millisecond)
var stream uint64
var outLocalIP unsafe.Pointer
outLocalIPArg := unsafe.Pointer(&outLocalIP)
var outLocalPort uint16
outLocalPortArg := unsafe.Pointer(&outLocalPort)
var outPeerIP unsafe.Pointer
outPeerIPArg := unsafe.Pointer(&outPeerIP)
var outPeerPort uint16
outPeerPortArg := unsafe.Pointer(&outPeerPort)
err := n.tcpAccept.call(
unsafe.Pointer(&stream),
unsafe.Pointer(&handle),
unsafe.Pointer(&timeoutMS),
unsafe.Pointer(&outLocalIPArg),
unsafe.Pointer(&outLocalPortArg),
unsafe.Pointer(&outPeerIPArg),
unsafe.Pointer(&outPeerPortArg),
)
if err != nil {
return 0, nil, nil, err
}
if stream == 0 {
return 0, nil, nil, n.lastError()
}
return stream, n.takeTCPAddr(outLocalIP, outLocalPort), n.takeTCPAddr(outPeerIP, outPeerPort), nil
}
func (n *Native) tcpReadFrom(handle uint64, buf []byte, timeout time.Duration) (int, error) {
if len(buf) == 0 {
return 0, nil
}
defer pinErrorThread()()
var ret int32
bufPtr := unsafe.Pointer(&buf[0])
length := uint32(len(buf))
timeoutMS := uint64(timeout / time.Millisecond)
err := n.tcpRead.call(unsafe.Pointer(&ret), unsafe.Pointer(&handle), unsafe.Pointer(&bufPtr), unsafe.Pointer(&length), unsafe.Pointer(&timeoutMS))
runtime.KeepAlive(buf)
if err != nil {
return 0, err
}
if ret < 0 {
return 0, n.lastError()
}
return int(ret), nil
}
func (n *Native) tcpWriteTo(handle uint64, buf []byte, timeout time.Duration) (int, error) {
if len(buf) == 0 {
return 0, nil
}
defer pinErrorThread()()
var ret int32
bufPtr := unsafe.Pointer(&buf[0])
length := uint32(len(buf))
timeoutMS := uint64(timeout / time.Millisecond)
err := n.tcpWrite.call(unsafe.Pointer(&ret), unsafe.Pointer(&handle), unsafe.Pointer(&bufPtr), unsafe.Pointer(&length), unsafe.Pointer(&timeoutMS))
runtime.KeepAlive(buf)
if err != nil {
return 0, err
}
if ret < 0 {
return 0, n.lastError()
}
return int(ret), nil
}
func (n *Native) tcpCloseHandle(handle uint64) error {
defer pinErrorThread()()
var ret int32
if err := n.tcpClose.call(unsafe.Pointer(&ret), unsafe.Pointer(&handle)); err != nil {
return err
}
if ret != 0 {
return n.lastError()
}
return nil
}
func (n *Native) tcpListenerCloseHandle(handle uint64) error {
defer pinErrorThread()()
var ret int32
if err := n.tcpListenerClose.call(unsafe.Pointer(&ret), unsafe.Pointer(&handle)); err != nil {
return err
}
if ret != 0 {
return n.lastError()
}
return nil
}
// pinErrorThread ties an FFI op to the get_error_msg that reads its result: the
// Rust side stores the last error in a thread-local, so the goroutine must not
// migrate to another OS thread between the two calls. Use as `defer pinErrorThread()()`
// at the start of any wrapper that reports failures through lastError.
func pinErrorThread() func() {
runtime.LockOSThread()
return runtime.UnlockOSThread
}
func (n *Native) lastError() error {
var out unsafe.Pointer
outArg := unsafe.Pointer(&out)
if err := n.getErrorMsg.call(nil, unsafe.Pointer(&outArg)); err != nil {
return err
}
if out == nil {
return errors.New("easytier ffi call failed")
}
msg := readCString(out)
_ = n.freeCString(out)
if strings.Contains(msg, "timed out") {
return timeoutError(msg)
}
return errors.New(msg)
}
func (n *Native) freeCString(ptr unsafe.Pointer) error {
if ptr == nil {
return nil
}
return n.freeString.call(nil, unsafe.Pointer(&ptr))
}
func (n *Native) takeTCPAddr(ipPtr unsafe.Pointer, port uint16) *net.TCPAddr {
if ipPtr == nil {
return nil
}
ip := net.ParseIP(readCString(ipPtr))
_ = n.freeCString(ipPtr)
return &net.TCPAddr{IP: ip, Port: int(port)}
}
func (d *atomicDeadline) set(t time.Time) {
if t.IsZero() {
d.v.Store(0)
return
}
d.v.Store(t.UnixNano())
}
func (d *atomicDeadline) timeout(fallback time.Duration) time.Duration {
ns := d.v.Load()
if ns == 0 {
return fallback
}
remaining := time.Until(time.Unix(0, ns))
if remaining <= 0 {
return time.Millisecond
}
return remaining
}
func (e timeoutError) Error() string { return string(e) }
func (e timeoutError) Timeout() bool { return true }
func (e timeoutError) Temporary() bool { return true }
func opError(op string, addr net.Addr, err error) error {
return &net.OpError{Op: op, Net: "easytier", Addr: addr, Err: err}
}
func parseIPPort(address string) (net.IP, int, error) {
host, portStr, err := net.SplitHostPort(address)
if err != nil {
return nil, 0, err
}
ip := net.ParseIP(host)
if ip == nil {
return nil, 0, fmt.Errorf("easytier ffi requires an IP address, got %q", host)
}
port, err := strconv.ParseUint(portStr, 10, 16)
if err != nil {
return nil, 0, err
}
return ip, int(port), nil
}
func parseListenPort(address string) (int, error) {
host, portStr, err := net.SplitHostPort(address)
if err != nil {
return 0, err
}
if host != "" {
ip := net.ParseIP(host)
if ip == nil {
return 0, fmt.Errorf("easytier ffi requires an IP address, got %q", host)
}
if !ip.IsUnspecified() {
return 0, fmt.Errorf("easytier ffi listen address must be unspecified, got %q", host)
}
}
port, err := strconv.ParseUint(portStr, 10, 16)
if err != nil {
return 0, err
}
return int(port), nil
}
func cString(s string) []byte {
if strings.ContainsRune(s, 0) {
panic("easytier ffi string contains NUL")
}
return append([]byte(s), 0)
}
func readCString(ptr unsafe.Pointer) string {
if ptr == nil {
return ""
}
var b []byte
for p := uintptr(ptr); ; p++ {
c := *(*byte)(unsafe.Pointer(p))
if c == 0 {
return string(b)
}
b = append(b, c)
}
}
func defaultLibraryPath() string {
if p := os.Getenv("EASYTIER_FFI_LIB"); p != "" {
return p
}
switch runtime.GOOS {
case "darwin":
return "../../../../target/debug/libeasytier_ffi.dylib"
case "windows":
return "..\\..\\..\\..\\target\\debug\\easytier_ffi.dll"
default:
return "../../../../target/debug/libeasytier_ffi.so"
}
}
var _ net.Conn = (*Conn)(nil)
var _ net.Listener = (*Listener)(nil)
File diff suppressed because it is too large Load Diff
@@ -1,360 +0,0 @@
package easytierffi
import (
"context"
"fmt"
"io"
"net"
"os"
"strconv"
"testing"
"time"
)
const asyncLocalTestTimeout = 120 * time.Second
func TestAsyncSymbolBinding(t *testing.T) {
n := openAsyncForTest(t)
status, err := n.opWaitStatus(0, 0)
if err != nil {
t.Fatal(err)
}
if status != dataPlaneOpInvalid {
t.Fatalf("expected invalid status for op 0, got %d", status)
}
}
func TestAsyncLocalTwoNodeTCPAndUDP(t *testing.T) {
n := openAsyncForTest(t)
topology := startLocalAsyncTopology(t, n)
ctx, cancel := context.WithTimeout(context.Background(), asyncLocalTestTimeout)
defer cancel()
runAsyncTCPPingPong(t, ctx, n, topology)
runAsyncUDPPingPong(t, ctx, n, topology)
}
type localAsyncTopology struct {
dialerInstance string
listenerInstance string
listenerIP string
}
func openAsyncForTest(t *testing.T) *AsyncNative {
t.Helper()
libraryPath := defaultLibraryPath()
if _, err := os.Stat(libraryPath); err != nil {
if os.IsNotExist(err) {
t.Skipf("build easytier-ffi with ffi-dataplane before running async tests: %v", err)
}
t.Fatalf("stat async ffi library: %v", err)
}
n, err := OpenAsync(libraryPath)
if err != nil {
t.Fatalf("open async ffi library: %v", err)
}
t.Cleanup(func() {
if err := n.Close(); err != nil {
t.Errorf("close async native: %v", err)
}
})
return n
}
func startLocalAsyncTopology(t *testing.T, n *AsyncNative) localAsyncTopology {
t.Helper()
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
networkName := "ffi-async-" + suffix
networkSecret := "ffi-async-secret-" + suffix
listenerInstance := "ffi-async-listener-" + suffix
dialerInstance := "ffi-async-dialer-" + suffix
listenerIP := "10.251.1.2"
dialerIP := "10.251.1.1"
listenerPort := freeLocalTCPPort(t)
listenerEndpoint := fmt.Sprintf("tcp://127.0.0.1:%d", listenerPort)
t.Cleanup(func() {
if err := n.deleteNetworkInstances([]string{dialerInstance, listenerInstance}); err != nil {
t.Errorf("cleanup async test EasyTier instances: %v", err)
}
})
listenerConfig := localAsyncConfig(
listenerInstance,
listenerIP,
networkName,
networkSecret,
[]string{listenerEndpoint},
nil,
)
dialerConfig := localAsyncConfig(
dialerInstance,
dialerIP,
networkName,
networkSecret,
nil,
[]string{listenerEndpoint},
)
if err := n.RunNetworkInstance(listenerConfig); err != nil {
t.Fatalf("start listener instance: %v", err)
}
if err := n.RunNetworkInstance(dialerConfig); err != nil {
t.Fatalf("start dialer instance: %v", err)
}
return localAsyncTopology{
dialerInstance: dialerInstance,
listenerInstance: listenerInstance,
listenerIP: listenerIP,
}
}
func localAsyncConfig(instance, ipv4, networkName, networkSecret string, listeners, peers []string) string {
config := fmt.Sprintf(`instance_name = %s
ipv4 = %s
listeners = %s
[network_identity]
network_name = %s
network_secret = %s
[flags]
no_tun = true
bind_device = false
`,
strconv.Quote(instance),
strconv.Quote(ipv4),
tomlStringList(listeners),
strconv.Quote(networkName),
strconv.Quote(networkSecret),
)
for _, peer := range peers {
config += fmt.Sprintf("\n[[peer]]\nuri = %s\n", strconv.Quote(peer))
}
return config
}
func tomlStringList(values []string) string {
if len(values) == 0 {
return "[]"
}
out := "["
for i, value := range values {
if i > 0 {
out += ", "
}
out += strconv.Quote(value)
}
return out + "]"
}
func freeLocalTCPPort(t *testing.T) int {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("allocate local tcp port: %v", err)
}
defer listener.Close()
return listener.Addr().(*net.TCPAddr).Port
}
func runAsyncTCPPingPong(t *testing.T, ctx context.Context, n *AsyncNative, topology localAsyncTopology) {
t.Helper()
listener, listenerAddr := eventuallyTCPListen(t, ctx, n, topology.listenerInstance)
tcpCtx, cancel := context.WithCancel(ctx)
accepted := make(chan error, 1)
defer waitForAsyncHelper(t, accepted, "tcp accept helper")
defer cancel()
defer listener.Close()
go func() {
conn, err := listener.Accept()
if err != nil {
accepted <- fmt.Errorf("accept tcp stream: %w", err)
return
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(30 * time.Second))
payload := make([]byte, len("ping"))
if _, err := io.ReadFull(conn, payload); err != nil {
accepted <- fmt.Errorf("read tcp ping: %w", err)
return
}
if string(payload) != "ping" {
accepted <- fmt.Errorf("expected tcp ping, got %q", string(payload))
return
}
if _, err := conn.Write([]byte("pong")); err != nil {
accepted <- fmt.Errorf("write tcp pong: %w", err)
return
}
accepted <- nil
}()
conn, err := eventuallyTCPDial(t, tcpCtx, n, topology.dialerInstance, topology.listenerIP, listenerAddr.Port)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(30 * time.Second))
if _, err := conn.Write([]byte("ping")); err != nil {
t.Fatalf("write tcp ping: %v", err)
}
payload := make([]byte, len("pong"))
if _, err := io.ReadFull(conn, payload); err != nil {
t.Fatalf("read tcp pong: %v", err)
}
if string(payload) != "pong" {
t.Fatalf("expected tcp pong, got %q", string(payload))
}
}
func eventuallyTCPListen(t *testing.T, ctx context.Context, n *AsyncNative, instance string) (net.Listener, *net.TCPAddr) {
t.Helper()
var lastErr error
for attempt := 1; ctx.Err() == nil; attempt++ {
attemptCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
listener, err := n.ListenContext(attemptCtx, instance, "tcp", "0.0.0.0:0")
cancel()
if err == nil {
addr := listener.Addr().(*net.TCPAddr)
t.Logf("async tcp bind succeeded on attempt %d at %s", attempt, addr)
return listener, addr
}
lastErr = err
t.Logf("attempt %d: async tcp bind failed: %v", attempt, err)
waitForRetry(ctx, 500*time.Millisecond)
}
t.Fatalf("async tcp bind never succeeded: %v", lastErr)
panic("unreachable")
}
func eventuallyTCPDial(t *testing.T, ctx context.Context, n *AsyncNative, instance, ip string, port int) (net.Conn, error) {
t.Helper()
address := net.JoinHostPort(ip, strconv.Itoa(port))
var lastErr error
for attempt := 1; ctx.Err() == nil; attempt++ {
attemptCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
conn, err := n.DialContext(attemptCtx, instance, "tcp", address)
cancel()
if err == nil {
t.Logf("async tcp connect succeeded on attempt %d to %s", attempt, address)
return conn, nil
}
lastErr = err
t.Logf("attempt %d: async tcp connect failed: %v", attempt, err)
waitForRetry(ctx, 500*time.Millisecond)
}
return nil, fmt.Errorf("async tcp connect never succeeded: %w", lastErr)
}
func runAsyncUDPPingPong(t *testing.T, ctx context.Context, n *AsyncNative, topology localAsyncTopology) {
t.Helper()
dialerSocket, err := n.UDPBindContext(ctx, topology.dialerInstance, 0)
if err != nil {
t.Fatalf("bind dialer udp socket: %v", err)
}
listenerSocket, err := n.UDPBindContext(ctx, topology.listenerInstance, 0)
if err != nil {
t.Fatalf("bind listener udp socket: %v", err)
}
udpCtx, cancel := context.WithCancel(ctx)
warmupDone := make(chan error, 1)
received := make(chan error, 1)
defer waitForAsyncHelper(t, received, "udp receive helper")
defer cancel()
defer listenerSocket.Close()
defer dialerSocket.Close()
go func() {
if _, err := listenerSocket.SendTo(udpCtx, []byte("warmup"), dialerSocket.LocalAddr()); err != nil {
err = fmt.Errorf("send udp warmup: %w", err)
warmupDone <- err
received <- err
return
}
warmupDone <- nil
payload, from, err := listenerSocket.RecvFrom(udpCtx, 512)
if err != nil {
received <- fmt.Errorf("recv udp ping: %w", err)
return
}
if string(payload) != "ping" {
received <- fmt.Errorf("expected udp ping, got %q", string(payload))
return
}
if _, err := listenerSocket.SendTo(udpCtx, []byte("pong"), from); err != nil {
received <- fmt.Errorf("send udp pong: %w", err)
return
}
received <- nil
}()
select {
case err := <-warmupDone:
if err != nil {
t.Fatal(err)
}
case <-udpCtx.Done():
t.Fatal(udpCtx.Err())
}
target := &net.UDPAddr{IP: net.ParseIP(topology.listenerIP), Port: listenerSocket.LocalAddr().Port}
if _, err := dialerSocket.SendTo(udpCtx, []byte("ping"), target); err != nil {
t.Fatalf("send udp ping: %v", err)
}
for {
payload, from, err := dialerSocket.RecvFrom(udpCtx, 512)
if err != nil {
t.Fatalf("recv udp pong: %v", err)
}
if string(payload) == "pong" {
if !from.IP.Equal(target.IP) || from.Port != target.Port {
t.Fatalf("expected udp pong from %s, got %s", target, from)
}
break
}
t.Logf("skipping udp datagram from %s: %q", from, string(payload))
}
}
func waitForAsyncHelper(t *testing.T, done <-chan error, name string) {
t.Helper()
select {
case err := <-done:
if err != nil {
t.Errorf("%s: %v", name, err)
}
case <-time.After(10 * time.Second):
t.Errorf("%s did not stop", name)
}
}
func waitForRetry(ctx context.Context, delay time.Duration) {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-timer.C:
case <-ctx.Done():
}
}
@@ -1,140 +0,0 @@
package easytierffi
import (
"context"
"fmt"
"io"
"net"
"os"
"strconv"
"strings"
"testing"
"time"
)
func TestSSHIntegration(t *testing.T) {
config := os.Getenv("EASYTIER_FFI_CONFIG")
instance := os.Getenv("EASYTIER_FFI_INSTANCE")
target := os.Getenv("EASYTIER_FFI_TARGET")
if config == "" || instance == "" || target == "" {
t.Skip("set EASYTIER_FFI_CONFIG, EASYTIER_FFI_INSTANCE and EASYTIER_FFI_TARGET to run integration test")
}
n, err := Open(defaultLibraryPath())
if err != nil {
t.Fatal(err)
}
defer n.Close()
if err := n.RunNetworkInstance(config); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
var lastErr error
for attempt := 1; ctx.Err() == nil; attempt++ {
conn, err := n.DialContext(ctx, instance, "tcp", target)
if err != nil {
lastErr = err
t.Logf("attempt %d: dial failed: %v", attempt, err)
time.Sleep(3 * time.Second)
continue
}
_ = conn.SetReadDeadline(time.Now().Add(10 * time.Second))
buf := make([]byte, 128)
nn, err := conn.Read(buf)
_ = conn.Close()
if err != nil {
lastErr = err
t.Logf("attempt %d: read failed: %v", attempt, err)
time.Sleep(3 * time.Second)
continue
}
banner := string(buf[:nn])
if !strings.HasPrefix(banner, "SSH-") {
t.Fatalf("attempt %d: expected SSH banner, got %q", attempt, banner)
}
t.Logf("attempt %d: got banner %q", attempt, strings.TrimRight(banner, "\r\n"))
return
}
t.Fatalf("never got SSH banner, last err: %v", lastErr)
}
func TestTCPListenIntegration(t *testing.T) {
config := os.Getenv("EASYTIER_FFI_LISTEN_CONFIG")
instance := os.Getenv("EASYTIER_FFI_LISTEN_INSTANCE")
listenPort := os.Getenv("EASYTIER_FFI_LISTEN_PORT")
if config == "" || instance == "" || listenPort == "" {
t.Skip("set EASYTIER_FFI_LISTEN_CONFIG, EASYTIER_FFI_LISTEN_INSTANCE and EASYTIER_FFI_LISTEN_PORT to run integration test")
}
port, err := strconv.ParseUint(listenPort, 10, 16)
if err != nil {
t.Fatal(err)
}
n, err := Open(defaultLibraryPath())
if err != nil {
t.Fatal(err)
}
defer n.Close()
if err := n.RunNetworkInstance(config); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
// Data-plane readiness is asynchronous: the instance must finish starting
// before the data plane accepts binds. Retry until ready or ctx expires.
var listener net.Listener
for attempt := 1; ; attempt++ {
listener, err = n.ListenContext(ctx, instance, "tcp", net.JoinHostPort("0.0.0.0", strconv.Itoa(int(port))))
if err == nil {
break
}
if ctx.Err() != nil {
t.Fatalf("bind never succeeded, last err: %v", err)
}
t.Logf("attempt %d: bind failed: %v", attempt, err)
time.Sleep(3 * time.Second)
}
t.Logf("listening on %s; connect from another EasyTier peer and send ping", listener.Addr())
accepted := make(chan error, 1)
go func() {
conn, err := listener.Accept()
if err != nil {
accepted <- err
return
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
buf := make([]byte, 4)
if _, err := io.ReadFull(conn, buf); err != nil {
accepted <- err
return
}
if string(buf) != "ping" {
accepted <- fmt.Errorf("expected %q, got %q", "ping", string(buf))
return
}
_, err = conn.Write([]byte("pong"))
accepted <- err
}()
select {
case err := <-accepted:
_ = listener.Close()
if err != nil {
t.Fatal(err)
}
case <-ctx.Done():
_ = listener.Close()
t.Fatal(ctx.Err())
}
}
@@ -1,5 +0,0 @@
module easytierffi-example
go 1.25
require github.com/go-webgpu/goffi v0.4.1
@@ -1,2 +0,0 @@
github.com/go-webgpu/goffi v0.4.1 h1:2hQH5XXloxTyTtIleYv+Rajlwzp6UOETURhSZ5+zJxU=
github.com/go-webgpu/goffi v0.4.1/go.mod h1:wfoxNsJkU+5RFbV1kNN1kunhc1lFHuJKK3zpgx08/uM=
@@ -1,575 +0,0 @@
use std::{
cell::Cell,
collections::HashSet,
ffi::{CString, c_char, c_int, c_void},
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
};
use easytier::{
common::{
MachineIdOptions,
config::{ConfigLoader as _, TomlConfigLoader},
},
tunnel::TunnelScheme,
web_client::{WebClient, WebClientHooks, run_web_client},
};
use uuid::Uuid;
use crate::{
data_plane::remove_data_plane_handles_by_instance_ids,
error::set_error_msg,
state::{
ASYNC_RUNTIME, INSTANCE_MANAGER, INSTANCE_MUTATION_LOCK, INSTANCE_NAME_ID_MAP,
lock_remote_instance_mutation, remove_instance_name_ids,
},
strings::{c_str_to_string, optional_c_str_to_string},
types::ConfigServerEventCallback,
};
thread_local! {
static IN_CONFIG_SERVER_CALLBACK: Cell<bool> = const { Cell::new(false) };
}
static CONFIG_SERVER_CLIENT: once_cell::sync::Lazy<Mutex<Option<ManagedConfigServerClient>>> =
once_cell::sync::Lazy::new(|| Mutex::new(None));
static CONFIG_SERVER_CLIENT_ACTIVE: once_cell::sync::Lazy<AtomicBool> =
once_cell::sync::Lazy::new(|| AtomicBool::new(false));
static CONFIG_SERVER_CLIENT_STOPPING: once_cell::sync::Lazy<AtomicBool> =
once_cell::sync::Lazy::new(|| AtomicBool::new(false));
static LAST_CONFIG_SERVER_CALLBACK_ERROR: once_cell::sync::Lazy<Mutex<Option<String>>> =
once_cell::sync::Lazy::new(|| Mutex::new(None));
pub(crate) struct ConfigServerCallbackScope;
impl ConfigServerCallbackScope {
pub(crate) fn enter() -> Self {
IN_CONFIG_SERVER_CALLBACK.with(|in_callback| in_callback.set(true));
Self
}
}
impl Drop for ConfigServerCallbackScope {
fn drop(&mut self) {
IN_CONFIG_SERVER_CALLBACK.with(|in_callback| in_callback.set(false));
}
}
pub fn in_config_server_callback() -> bool {
IN_CONFIG_SERVER_CALLBACK.with(Cell::get)
}
fn config_server_machine_id_options(machine_id: String) -> MachineIdOptions {
MachineIdOptions {
explicit_machine_id: Some(machine_id),
state_dir: None,
}
}
pub fn validate_config_server_client_options(
config_server_url_s: &str,
machine_id: &str,
) -> Result<(), String> {
if machine_id.trim().is_empty() {
return Err("machine_id is empty".to_string());
}
let config_server_url = match url::Url::parse(config_server_url_s) {
Ok(url) => url,
Err(_) => format!(
"udp://config-server.easytier.cn:22020/{}",
config_server_url_s
)
.parse()
.map_err(|err| format!("failed to parse config server URL: {}", err))?,
};
TunnelScheme::try_from(&config_server_url).map_err(|_| {
format!(
"unsupported config server scheme: {}",
config_server_url.scheme()
)
})?;
let token = config_server_url
.path_segments()
.and_then(|mut segments| segments.next_back())
.map(|segment| percent_encoding::percent_decode_str(segment).decode_utf8())
.transpose()
.map_err(|err| format!("failed to decode config server token: {}", err))?
.map(|token| token.to_string())
.unwrap_or_default();
if token.is_empty() {
return Err("empty token".to_string());
}
Ok(())
}
struct ManagedConfigServerClient {
client: WebClient,
hooks: Arc<ManagedConfigServerClientHooks>,
}
pub(crate) struct ManagedConfigServerClientHooks {
pub(crate) instance_ids: Mutex<HashSet<Uuid>>,
callback_delivery: Mutex<()>,
stopping: AtomicBool,
callback: ConfigServerEventCallback,
user_data: usize,
}
impl ManagedConfigServerClientHooks {
pub(crate) fn new(callback: ConfigServerEventCallback, user_data: *mut c_void) -> Self {
Self {
instance_ids: Mutex::new(HashSet::new()),
callback_delivery: Mutex::new(()),
stopping: AtomicBool::new(false),
callback,
user_data: user_data as usize,
}
}
#[cfg(test)]
pub(crate) fn tracked_instance_ids(&self) -> Vec<Uuid> {
self.instance_ids
.lock()
.map(|guard| guard.iter().copied().collect())
.unwrap_or_default()
}
fn remove_tracked_instance_ids(&self, ids: &[Uuid]) -> Result<Vec<Uuid>, String> {
let mut guard = self.instance_ids.lock().map_err(|err| err.to_string())?;
Ok(ids
.iter()
.filter_map(|id| guard.remove(id).then_some(*id))
.collect())
}
fn validate_instance_name(&self, inst_name: &str, inst_id: Uuid) -> Result<(), String> {
if let Some(existing_id) = INSTANCE_NAME_ID_MAP.get(inst_name).map(|id| *id)
&& existing_id != inst_id
{
return Err(format!("instance name {} already exists", inst_name));
}
Ok(())
}
fn commit_instance_name(&self, inst_name: String, inst_id: Uuid) -> Result<(), String> {
INSTANCE_NAME_ID_MAP.retain(|_, existing_id| *existing_id != inst_id);
self.validate_instance_name(&inst_name, inst_id)?;
INSTANCE_NAME_ID_MAP.insert(inst_name, inst_id);
Ok(())
}
pub(crate) fn start_stopping(&self) -> Vec<Uuid> {
let _delivery_guard = if in_config_server_callback() {
None
} else {
self.callback_delivery.lock().ok()
};
let mut guard = match self.instance_ids.lock() {
Ok(guard) => guard,
Err(_) => return Vec::new(),
};
self.stopping.store(true, Ordering::Release);
guard.drain().collect()
}
pub(crate) fn note_callback_error(&self, error: String) {
log::warn!("config server event callback failed: {}", error);
if let Ok(mut guard) = LAST_CONFIG_SERVER_CALLBACK_ERROR.lock() {
*guard = Some(error);
}
}
fn emit_event_with_delivery_locked(
&self,
event: &str,
instance_id: Uuid,
) -> Result<(), String> {
if self.stopping.load(Ordering::Acquire) {
return Ok(());
}
let Some(callback) = self.callback else {
return Ok(());
};
let instance_name = INSTANCE_MANAGER
.get_instance_name(&instance_id)
.unwrap_or_default();
let network_name = INSTANCE_MANAGER
.get_network_name(&instance_id)
.unwrap_or_default();
let event_json = serde_json::json!({
"event": event,
"success": true,
"instance_id": instance_id.to_string(),
"instance_name": instance_name,
"network_name": network_name,
"error": null,
})
.to_string();
let event_json = CString::new(event_json).map_err(|err| err.to_string())?;
let _callback_scope = ConfigServerCallbackScope::enter();
unsafe {
callback(event_json.as_ptr(), self.user_data as *mut c_void);
}
Ok(())
}
fn emit_event(&self, event: &str, instance_id: Uuid) -> Result<(), String> {
let _delivery_guard = self
.callback_delivery
.lock()
.map_err(|err| err.to_string())?;
self.emit_event_with_delivery_locked(event, instance_id)
}
fn wait_for_callback_delivery(&self) {
if in_config_server_callback() {
return;
}
if let Ok(guard) = self.callback_delivery.lock() {
drop(guard);
}
}
}
#[async_trait::async_trait]
impl WebClientHooks for ManagedConfigServerClientHooks {
fn manages_remote_config_instances(&self) -> bool {
true
}
async fn pre_run_network_instance(&self, cfg: &TomlConfigLoader) -> Result<(), String> {
if self.stopping.load(Ordering::Acquire) {
return Err("config server client is stopping".to_string());
}
let inst_name = cfg.get_inst_name();
let inst_id = cfg.get_id();
self.validate_instance_name(&inst_name, inst_id)
}
async fn post_run_network_instance(&self, id: &Uuid) -> Result<(), String> {
let _delivery_guard = self
.callback_delivery
.lock()
.map_err(|err| err.to_string())?;
let Some(inst_name) = INSTANCE_MANAGER.get_instance_name(id) else {
if !self.stopping.load(Ordering::Acquire) {
return Err(format!("instance {} not found after start", id));
}
return Ok(());
};
{
let _mutation_guard = INSTANCE_MUTATION_LOCK
.lock()
.map_err(|err| err.to_string())?;
if INSTANCE_MANAGER.get_instance_name(id).is_none() {
if !self.stopping.load(Ordering::Acquire) {
return Err(format!("instance {} not found after start", id));
}
return Ok(());
}
let should_delete = {
let mut guard = self.instance_ids.lock().map_err(|err| err.to_string())?;
if self.stopping.load(Ordering::Acquire) {
true
} else {
guard.insert(*id);
false
}
};
if should_delete {
if let Err(err) = INSTANCE_MANAGER.delete_network_instance(vec![*id]) {
return Err(err.to_string());
}
remove_instance_name_ids(&[*id]);
return Ok(());
}
if self.stopping.load(Ordering::Acquire) {
self.remove_tracked_instance_ids(&[*id])?;
remove_instance_name_ids(&[*id]);
return Ok(());
}
if let Err(err) = self.commit_instance_name(inst_name.clone(), *id) {
self.remove_tracked_instance_ids(&[*id])?;
if let Err(delete_err) = INSTANCE_MANAGER.delete_network_instance(vec![*id]) {
return Err(format!(
"{}; failed to delete duplicate instance: {}",
err, delete_err
));
}
return Err(err);
}
if self.stopping.load(Ordering::Acquire) {
self.remove_tracked_instance_ids(&[*id])?;
remove_instance_name_ids(&[*id]);
return Ok(());
}
if INSTANCE_MANAGER.get_instance_name(id).is_none() {
self.remove_tracked_instance_ids(&[*id])?;
remove_instance_name_ids(&[*id]);
return Err(format!(
"instance {} was removed before post-run completed",
id
));
}
}
remove_data_plane_handles_by_instance_ids(&[*id]);
if let Err(err) = self.emit_event_with_delivery_locked("run_network_instance", *id) {
self.note_callback_error(err);
}
Ok(())
}
async fn post_remove_network_instances(&self, ids: &[Uuid]) -> Result<(), String> {
let removed_ids = {
let _mutation_guard = INSTANCE_MUTATION_LOCK
.lock()
.map_err(|err| err.to_string())?;
let removed_ids = self.remove_tracked_instance_ids(ids)?;
remove_instance_name_ids(ids);
remove_data_plane_handles_by_instance_ids(&removed_ids);
removed_ids
};
for id in removed_ids {
if let Err(err) = self.emit_event("delete_network_instance", id) {
self.note_callback_error(err);
}
}
Ok(())
}
}
pub(crate) fn remove_config_server_tracked_instance_ids(ids: &[Uuid]) {
if ids.is_empty() {
return;
}
if let Ok(guard) = CONFIG_SERVER_CLIENT.lock()
&& let Some(managed) = guard.as_ref()
&& let Err(err) = managed.hooks.remove_tracked_instance_ids(ids)
{
log::warn!("failed to remove config server tracked ids: {}", err);
}
}
pub(crate) fn wait_for_config_server_delivery() {
let hooks = CONFIG_SERVER_CLIENT
.lock()
.ok()
.and_then(|guard| guard.as_ref().map(|managed| managed.hooks.clone()));
if let Some(hooks) = hooks {
hooks.wait_for_callback_delivery();
}
}
pub(crate) fn last_callback_error() -> Option<String> {
LAST_CONFIG_SERVER_CALLBACK_ERROR
.lock()
.ok()
.and_then(|guard| guard.clone())
}
pub(crate) fn clear_last_callback_error() {
if let Ok(mut guard) = LAST_CONFIG_SERVER_CALLBACK_ERROR.lock() {
*guard = None;
}
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn is_config_server_active_or_stopping() -> bool {
CONFIG_SERVER_CLIENT_ACTIVE.load(Ordering::Acquire)
|| CONFIG_SERVER_CLIENT_STOPPING.load(Ordering::Acquire)
}
#[cfg(test)]
pub(crate) fn set_active_for_test(active: bool) {
CONFIG_SERVER_CLIENT_ACTIVE.store(active, Ordering::Release);
}
/// # Safety
/// Start the config server client.
///
/// `config_server_url` must be a valid null-terminated UTF-8 string.
/// `hostname` may be null; if non-null it must be a valid null-terminated UTF-8 string.
/// `machine_id` must be a valid null-terminated UTF-8 string.
/// `event_json` passed to `callback` is valid only during that callback invocation.
pub(crate) unsafe fn start_config_server_client(
config_server_url: *const c_char,
hostname: *const c_char,
machine_id: *const c_char,
secure_mode: bool,
callback: ConfigServerEventCallback,
user_data: *mut c_void,
) -> c_int {
if in_config_server_callback() {
set_error_msg("cannot start config server client from config server callback");
return -1;
}
let config_server_url = match unsafe { c_str_to_string(config_server_url, "config_server_url") }
{
Ok(value) => value,
Err(err) => {
set_error_msg(&err);
return -1;
}
};
let hostname = match unsafe { optional_c_str_to_string(hostname, "hostname") } {
Ok(value) => value,
Err(err) => {
set_error_msg(&err);
return -1;
}
};
let machine_id = match unsafe { c_str_to_string(machine_id, "machine_id") } {
Err(err) => {
set_error_msg(&err);
return -1;
}
Ok(value) => value,
};
if let Err(err) = validate_config_server_client_options(&config_server_url, &machine_id) {
set_error_msg(&err);
return -1;
}
let mut guard = match CONFIG_SERVER_CLIENT.lock() {
Ok(guard) => guard,
Err(err) => {
set_error_msg(&format!("failed to lock config server client: {}", err));
return -1;
}
};
if guard.is_some() {
set_error_msg("config server client already exists");
return -1;
}
if CONFIG_SERVER_CLIENT_STOPPING.load(Ordering::Acquire) {
set_error_msg("config server client is stopping");
return -1;
}
clear_last_callback_error();
#[cfg(feature = "ffi-dataplane")]
let data_plane_usage_guard = match crate::data_plane::lock_for_config_server_start() {
Ok(guard) => guard,
Err(err) => {
set_error_msg(&err);
return -1;
}
};
CONFIG_SERVER_CLIENT_ACTIVE.store(true, Ordering::Release);
#[cfg(feature = "ffi-dataplane")]
drop(data_plane_usage_guard);
let hooks = Arc::new(ManagedConfigServerClientHooks::new(callback, user_data));
let client = match ASYNC_RUNTIME.block_on(run_web_client(
&config_server_url,
config_server_machine_id_options(machine_id),
hostname,
secure_mode,
INSTANCE_MANAGER.clone(),
Some(hooks.clone()),
)) {
Ok(client) => client,
Err(err) => {
CONFIG_SERVER_CLIENT_ACTIVE.store(false, Ordering::Release);
set_error_msg(&format!("failed to start config server client: {}", err));
return -1;
}
};
*guard = Some(ManagedConfigServerClient { client, hooks });
0
}
pub(crate) fn stop_config_server_client() -> c_int {
if in_config_server_callback() {
set_error_msg("cannot stop config server client from config server callback");
return -1;
}
let mut guard = match CONFIG_SERVER_CLIENT.lock() {
Ok(guard) => guard,
Err(err) => {
set_error_msg(&format!("failed to lock config server client: {}", err));
return -1;
}
};
let Some(managed) = guard.as_ref() else {
CONFIG_SERVER_CLIENT_ACTIVE.store(false, Ordering::Release);
return 0;
};
if CONFIG_SERVER_CLIENT_STOPPING.swap(true, Ordering::AcqRel) {
set_error_msg("config server client is stopping");
return -1;
}
let hooks = managed.hooks.clone();
let managed = guard.take().expect("config server client exists");
drop(guard);
let _remote_mutation_guard = lock_remote_instance_mutation();
let tracked_ids = hooks.start_stopping();
drop(managed);
let _mutation_guard = match INSTANCE_MUTATION_LOCK.lock() {
Ok(guard) => guard,
Err(err) => {
hooks.wait_for_callback_delivery();
CONFIG_SERVER_CLIENT_ACTIVE.store(false, Ordering::Release);
CONFIG_SERVER_CLIENT_STOPPING.store(false, Ordering::Release);
set_error_msg(&format!("failed to lock instance mutation: {}", err));
return -1;
}
};
let delete_result = INSTANCE_MANAGER.delete_network_instance(tracked_ids.clone());
if delete_result.is_ok() {
remove_instance_name_ids(&tracked_ids);
remove_data_plane_handles_by_instance_ids(&tracked_ids);
}
drop(_mutation_guard);
hooks.wait_for_callback_delivery();
CONFIG_SERVER_CLIENT_ACTIVE.store(false, Ordering::Release);
CONFIG_SERVER_CLIENT_STOPPING.store(false, Ordering::Release);
if let Err(err) = delete_result {
set_error_msg(&format!(
"failed to delete config server instances: {}",
err
));
return -1;
}
0
}
pub(crate) fn is_config_server_client_connected() -> c_int {
CONFIG_SERVER_CLIENT
.lock()
.ok()
.and_then(|guard| guard.as_ref().map(|managed| managed.client.is_connected()))
.map(i32::from)
.unwrap_or(0)
}
@@ -1,928 +0,0 @@
#[cfg(feature = "ffi-dataplane")]
use std::{
future::Future,
net::{IpAddr, SocketAddr},
sync::{
Arc, RwLock,
atomic::{AtomicU64, Ordering},
},
time::Duration,
};
#[cfg(feature = "ffi-dataplane")]
use dashmap::DashMap;
#[cfg(feature = "ffi-dataplane")]
use easytier::launcher::{DataPlaneTcpListener, DataPlaneTcpStream, DataPlaneUdpSocket};
#[cfg(feature = "ffi-dataplane")]
use tokio::io::{AsyncReadExt, AsyncWriteExt, ReadHalf, WriteHalf};
#[cfg(feature = "ffi-dataplane")]
use tokio_util::sync::CancellationToken;
#[cfg(feature = "ffi-dataplane")]
use uuid::Uuid;
#[cfg(feature = "ffi-dataplane")]
use crate::{
config_server::{in_config_server_callback, is_config_server_active_or_stopping},
error::{free_string, set_error_msg},
state::{INSTANCE_MANAGER, INSTANCE_NAME_ID_MAP},
};
#[cfg(feature = "ffi-dataplane")]
static NEXT_DATA_PLANE_HANDLE: AtomicU64 = AtomicU64::new(1);
#[cfg(feature = "ffi-dataplane")]
static DATA_PLANE_HANDLES: once_cell::sync::Lazy<DashMap<u64, DataPlaneHandle>> =
once_cell::sync::Lazy::new(DashMap::new);
#[cfg(feature = "ffi-dataplane")]
static DATA_PLANE_USAGE_LOCK: once_cell::sync::Lazy<RwLock<()>> =
once_cell::sync::Lazy::new(|| RwLock::new(()));
#[cfg(feature = "ffi-dataplane")]
pub(crate) struct DataPlaneHandle {
pub(crate) instance_id: uuid::Uuid,
pub(crate) runtime: tokio::runtime::Handle,
// Cancelled by close() to wake any in-flight op on this handle.
pub(crate) close_token: CancellationToken,
pub(crate) resource: DataPlaneResource,
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) struct TcpHalves {
pub(crate) read: tokio::sync::Mutex<ReadHalf<DataPlaneTcpStream>>,
pub(crate) write: tokio::sync::Mutex<WriteHalf<DataPlaneTcpStream>>,
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) enum DataPlaneResource {
Tcp(Arc<TcpHalves>),
TcpListener(Arc<tokio::sync::Mutex<DataPlaneTcpListener>>),
Udp(Arc<DataPlaneUdpSocket>),
}
// Several helper functions for FFI data plane operations to facilitate logic reuse.
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn next_handle() -> u64 {
NEXT_DATA_PLANE_HANDLE.fetch_add(1, Ordering::Relaxed)
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn timeout_duration(timeout_ms: u64) -> Duration {
Duration::from_millis(timeout_ms)
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) unsafe fn cstr_to_string(ptr: *const std::ffi::c_char, name: &str) -> Option<String> {
if ptr.is_null() {
set_error_msg(&format!("{} is null", name));
return None;
}
Some(
unsafe { std::ffi::CStr::from_ptr(ptr) }
.to_string_lossy()
.into_owned(),
)
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn get_instance_id(inst_name: &str) -> Option<uuid::Uuid> {
INSTANCE_NAME_ID_MAP.get(inst_name).map(|id| *id.value())
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn parse_socket_addr(host: &str, port: u16) -> Option<SocketAddr> {
let ip = match host.parse::<IpAddr>() {
Ok(ip) => ip,
Err(e) => {
set_error_msg(&format!("failed to parse ip address: {}", e));
return None;
}
};
Some(SocketAddr::new(ip, port))
}
/// Encode an IP address for FFI return. Returns `*mut c_char` to match
/// `CString::into_raw`; caller releases it via `free_string`.
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn into_ffi_ip_cstring(ip: IpAddr) -> Option<*mut std::ffi::c_char> {
match std::ffi::CString::new(ip.to_string()) {
Ok(s) => Some(s.into_raw()),
Err(e) => {
set_error_msg(&format!("failed to encode ip: {}", e));
None
}
}
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn get_runtime_handle(
inst_id: &uuid::Uuid,
deadline: std::time::Instant,
) -> Option<tokio::runtime::Handle> {
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
let Some(rt) = INSTANCE_MANAGER.data_plane_wait_runtime_handle(inst_id, remaining) else {
set_error_msg("instance runtime is not ready");
return None;
};
Some(rt)
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn insert_tcp_stream_handle(
instance_id: uuid::Uuid,
runtime: tokio::runtime::Handle,
stream: DataPlaneTcpStream,
) -> u64 {
let (rd, wr) = tokio::io::split(stream);
let handle = next_handle();
DATA_PLANE_HANDLES.insert(
handle,
DataPlaneHandle {
instance_id,
runtime,
close_token: CancellationToken::new(),
resource: DataPlaneResource::Tcp(Arc::new(TcpHalves {
read: tokio::sync::Mutex::new(rd),
write: tokio::sync::Mutex::new(wr),
})),
},
);
handle
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn insert_tcp_listener_handle(
instance_id: uuid::Uuid,
runtime: tokio::runtime::Handle,
listener: DataPlaneTcpListener,
) -> u64 {
let handle = next_handle();
DATA_PLANE_HANDLES.insert(
handle,
DataPlaneHandle {
instance_id,
runtime,
close_token: CancellationToken::new(),
resource: DataPlaneResource::TcpListener(Arc::new(tokio::sync::Mutex::new(listener))),
},
);
handle
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn insert_udp_socket_handle(
instance_id: uuid::Uuid,
runtime: tokio::runtime::Handle,
socket: DataPlaneUdpSocket,
) -> u64 {
let handle = next_handle();
DATA_PLANE_HANDLES.insert(
handle,
DataPlaneHandle {
instance_id,
runtime,
close_token: CancellationToken::new(),
resource: DataPlaneResource::Udp(Arc::new(socket)),
},
);
handle
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn get_tcp_stream(
handle: u64,
) -> Option<(Arc<TcpHalves>, tokio::runtime::Handle, CancellationToken)> {
get_tcp_stream_with_instance(handle)
.map(|(halves, runtime, close_token, _)| (halves, runtime, close_token))
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn get_tcp_stream_with_instance(
handle: u64,
) -> Option<(
Arc<TcpHalves>,
tokio::runtime::Handle,
CancellationToken,
uuid::Uuid,
)> {
let Some(h) = DATA_PLANE_HANDLES.get(&handle) else {
set_error_msg("tcp stream handle not found");
return None;
};
match &h.resource {
DataPlaneResource::Tcp(halves) => Some((
halves.clone(),
h.runtime.clone(),
h.close_token.clone(),
h.instance_id,
)),
DataPlaneResource::TcpListener(_) | DataPlaneResource::Udp(_) => {
set_error_msg("handle is not a tcp stream");
None
}
}
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn get_tcp_listener(
handle: u64,
) -> Option<(
Arc<tokio::sync::Mutex<DataPlaneTcpListener>>,
tokio::runtime::Handle,
CancellationToken,
uuid::Uuid,
)> {
let Some(h) = DATA_PLANE_HANDLES.get(&handle) else {
set_error_msg("tcp listener handle not found");
return None;
};
match &h.resource {
DataPlaneResource::TcpListener(listener) => Some((
listener.clone(),
h.runtime.clone(),
h.close_token.clone(),
h.instance_id,
)),
DataPlaneResource::Tcp(_) | DataPlaneResource::Udp(_) => {
set_error_msg("handle is not a tcp listener");
None
}
}
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn get_udp_socket(
handle: u64,
) -> Option<(
Arc<DataPlaneUdpSocket>,
tokio::runtime::Handle,
CancellationToken,
)> {
get_udp_socket_with_instance(handle)
.map(|(socket, runtime, close_token, _)| (socket, runtime, close_token))
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn get_udp_socket_with_instance(
handle: u64,
) -> Option<(
Arc<DataPlaneUdpSocket>,
tokio::runtime::Handle,
CancellationToken,
uuid::Uuid,
)> {
let Some(h) = DATA_PLANE_HANDLES.get(&handle) else {
set_error_msg("udp socket handle not found");
return None;
};
match &h.resource {
DataPlaneResource::Udp(socket) => Some((
socket.clone(),
h.runtime.clone(),
h.close_token.clone(),
h.instance_id,
)),
DataPlaneResource::Tcp(_) | DataPlaneResource::TcpListener(_) => {
set_error_msg("handle is not a udp socket");
None
}
}
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn remove_data_plane_handles_by_instance_ids(ids: &[Uuid]) {
if ids.is_empty() {
return;
}
let _data_plane_usage_guard = DATA_PLANE_USAGE_LOCK
.write()
.unwrap_or_else(|err| err.into_inner());
DATA_PLANE_HANDLES.retain(|_, handle| {
if ids.contains(&handle.instance_id) {
handle.close_token.cancel();
false
} else {
true
}
});
crate::data_plane_async::remove_ops_by_instance_ids(ids);
}
#[cfg(not(feature = "ffi-dataplane"))]
pub(crate) fn remove_data_plane_handles_by_instance_ids(_ids: &[uuid::Uuid]) {}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn data_plane_rejected() -> bool {
if in_config_server_callback() {
set_error_msg("cannot use data plane from config server callback");
true
} else if is_config_server_active_or_stopping() {
set_error_msg("cannot use data plane while config server client is active");
true
} else {
false
}
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn enter_data_plane_operation() -> Option<std::sync::RwLockReadGuard<'static, ()>> {
if data_plane_rejected() {
return None;
}
let guard = match DATA_PLANE_USAGE_LOCK.read() {
Ok(guard) => guard,
Err(err) => {
set_error_msg(&format!("failed to lock data plane usage: {}", err));
return None;
}
};
if data_plane_rejected() {
return None;
}
Some(guard)
}
/// Run an IO op on the resource's owning runtime, supporting
/// timeout and cancellation.
#[cfg(feature = "ffi-dataplane")]
async fn run_with_cancel<T, F>(
close_token: &CancellationToken,
timeout_ms: u64,
error_prefix: &str,
op: F,
) -> Option<Result<T, std::io::Error>>
where
F: Future<Output = Result<T, std::io::Error>>,
{
tokio::select! {
biased;
_ = close_token.cancelled() => {
set_error_msg(&format!("{}: handle closed", error_prefix));
None
}
res = tokio::time::timeout(timeout_duration(timeout_ms), op) => match res {
Ok(r) => Some(r),
Err(_) => {
set_error_msg(&format!("{} timed out", error_prefix));
None
}
}
}
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn lock_for_config_server_start()
-> Result<std::sync::RwLockWriteGuard<'static, ()>, String> {
let guard = DATA_PLANE_USAGE_LOCK
.write()
.map_err(|err| format!("failed to lock data plane usage: {}", err))?;
if !DATA_PLANE_HANDLES.is_empty() || crate::data_plane_async::has_live_ops() {
return Err("cannot start config server client while data plane is in use".to_string());
}
Ok(guard)
}
/// # Safety
/// Open a TCP stream through an EasyTier instance data plane. Returns 0 on
/// failure. On success, writes the local socket address chosen for this
/// connection into `out_local_ip` (a heap-allocated C string the caller must
/// release via `free_string`) and `out_local_port`. Both out pointers must be
/// non-null.
#[cfg(feature = "ffi-dataplane")]
pub(crate) unsafe fn data_plane_tcp_connect(
inst_name: *const std::ffi::c_char,
dst_ip: *const std::ffi::c_char,
dst_port: std::ffi::c_ushort,
timeout_ms: u64,
out_local_ip: *mut *const std::ffi::c_char,
out_local_port: *mut std::ffi::c_ushort,
) -> u64 {
let _data_plane_usage_guard = match enter_data_plane_operation() {
Some(guard) => guard,
None => return 0,
};
if out_local_ip.is_null() || out_local_port.is_null() {
set_error_msg("output pointer is null");
return 0;
}
let Some(inst_name) = (unsafe { cstr_to_string(inst_name, "inst_name") }) else {
return 0;
};
let Some(dst_ip) = (unsafe { cstr_to_string(dst_ip, "dst_ip") }) else {
return 0;
};
let Some(inst_id) = get_instance_id(&inst_name) else {
set_error_msg("instance not found");
return 0;
};
let Some(dst_addr) = parse_socket_addr(&dst_ip, dst_port) else {
return 0;
};
let deadline = std::time::Instant::now() + timeout_duration(timeout_ms);
let Some(runtime) = get_runtime_handle(&inst_id, deadline) else {
return 0;
};
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
let result =
runtime.block_on(INSTANCE_MANAGER.data_plane_tcp_connect(&inst_id, dst_addr, remaining));
match result {
Ok(stream) => {
let local_addr = stream.local_addr();
let Some(local_ip) = into_ffi_ip_cstring(local_addr.ip()) else {
return 0;
};
let handle = insert_tcp_stream_handle(inst_id, runtime, stream);
unsafe {
*out_local_ip = local_ip as *const std::ffi::c_char;
*out_local_port = local_addr.port();
}
handle
}
Err(e) => {
set_error_msg(&format!("failed to connect tcp data plane: {}", e));
0
}
}
}
/// # Safety
/// Bind a TCP listener through an EasyTier instance data plane. Returns 0 on
/// failure. The local address actually bound is written into `out_local_ip` /
/// `out_local_port`; the caller must release `*out_local_ip` via `free_string`.
#[cfg(feature = "ffi-dataplane")]
pub(crate) unsafe fn data_plane_tcp_bind(
inst_name: *const std::ffi::c_char,
local_port: std::ffi::c_ushort,
timeout_ms: u64,
out_local_ip: *mut *const std::ffi::c_char,
out_local_port: *mut std::ffi::c_ushort,
) -> u64 {
let _data_plane_usage_guard = match enter_data_plane_operation() {
Some(guard) => guard,
None => return 0,
};
if out_local_ip.is_null() || out_local_port.is_null() {
set_error_msg("output pointer is null");
return 0;
}
let Some(inst_name) = (unsafe { cstr_to_string(inst_name, "inst_name") }) else {
return 0;
};
let Some(inst_id) = get_instance_id(&inst_name) else {
set_error_msg("instance not found");
return 0;
};
let deadline = std::time::Instant::now() + timeout_duration(timeout_ms);
let Some(runtime) = get_runtime_handle(&inst_id, deadline) else {
return 0;
};
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
let result =
runtime.block_on(INSTANCE_MANAGER.data_plane_tcp_bind(&inst_id, local_port, remaining));
match result {
Ok(listener) => {
let local_addr = listener.local_addr();
let Some(local_ip) = into_ffi_ip_cstring(local_addr.ip()) else {
return 0;
};
let handle = insert_tcp_listener_handle(inst_id, runtime, listener);
unsafe {
*out_local_ip = local_ip as *const std::ffi::c_char;
*out_local_port = local_addr.port();
}
handle
}
Err(e) => {
set_error_msg(&format!("failed to bind tcp data plane: {}", e));
0
}
}
}
/// # Safety
/// Accept one connection from a TCP data-plane listener. Returns a TCP stream
/// handle, or 0 on failure. Local and peer addresses are written into out
/// parameters; returned IP strings must be released via `free_string`.
#[cfg(feature = "ffi-dataplane")]
pub(crate) unsafe fn data_plane_tcp_accept(
handle: u64,
timeout_ms: u64,
out_local_ip: *mut *const std::ffi::c_char,
out_local_port: *mut std::ffi::c_ushort,
out_peer_ip: *mut *const std::ffi::c_char,
out_peer_port: *mut std::ffi::c_ushort,
) -> u64 {
let _data_plane_usage_guard = match enter_data_plane_operation() {
Some(guard) => guard,
None => return 0,
};
if out_local_ip.is_null()
|| out_local_port.is_null()
|| out_peer_ip.is_null()
|| out_peer_port.is_null()
{
set_error_msg("output pointer is null");
return 0;
}
let Some((listener, runtime, close_token, instance_id)) = get_tcp_listener(handle) else {
return 0;
};
let ret = runtime.block_on(async move {
let mut listener = listener.lock().await;
run_with_cancel(
&close_token,
timeout_ms,
"tcp data plane accept",
listener.accept(),
)
.await
});
match ret {
Some(Ok((stream, peer_addr))) => {
let local_addr = stream.local_addr();
let Some(local_ip) = into_ffi_ip_cstring(local_addr.ip()) else {
return 0;
};
let Some(peer_ip) = into_ffi_ip_cstring(peer_addr.ip()) else {
free_string(local_ip);
return 0;
};
let stream_handle = insert_tcp_stream_handle(instance_id, runtime, stream);
unsafe {
*out_local_ip = local_ip as *const std::ffi::c_char;
*out_local_port = local_addr.port();
*out_peer_ip = peer_ip as *const std::ffi::c_char;
*out_peer_port = peer_addr.port();
}
stream_handle
}
Some(Err(e)) => {
set_error_msg(&format!("failed to accept tcp data plane: {}", e));
0
}
None => 0,
}
}
/// # Safety
/// Read from a TCP data-plane stream.
#[cfg(feature = "ffi-dataplane")]
pub(crate) unsafe fn data_plane_tcp_read(
handle: u64,
buf: *mut std::ffi::c_uchar,
len: u32,
timeout_ms: u64,
) -> std::ffi::c_int {
let _data_plane_usage_guard = match enter_data_plane_operation() {
Some(guard) => guard,
None => return -1,
};
if buf.is_null() {
set_error_msg("buf is null");
return -1;
}
let Some((halves, runtime, close_token)) = get_tcp_stream(handle) else {
return -1;
};
// Safety: caller-owned buffer outlives this blocking call.
let buf = unsafe { std::slice::from_raw_parts_mut(buf, len as usize) };
runtime.block_on(async move {
let mut rd = halves.read.lock().await;
match run_with_cancel(
&close_token,
timeout_ms,
"failed to read tcp data plane",
rd.read(buf),
)
.await
{
Some(Ok(n)) => n as std::ffi::c_int,
Some(Err(e)) => {
set_error_msg(&format!("failed to read tcp data plane: {}", e));
-1
}
None => -1,
}
})
}
/// # Safety
/// Write to a TCP data-plane stream.
#[cfg(feature = "ffi-dataplane")]
pub(crate) unsafe fn data_plane_tcp_write(
handle: u64,
buf: *const std::ffi::c_uchar,
len: u32,
timeout_ms: u64,
) -> std::ffi::c_int {
let _data_plane_usage_guard = match enter_data_plane_operation() {
Some(guard) => guard,
None => return -1,
};
if buf.is_null() {
set_error_msg("buf is null");
return -1;
}
let Some((halves, runtime, close_token)) = get_tcp_stream(handle) else {
return -1;
};
let total = len as usize;
// Safety: caller-owned buffer outlives this blocking call.
let buf = unsafe { std::slice::from_raw_parts(buf, total) };
runtime.block_on(async move {
let mut wr = halves.write.lock().await;
// Use `write_all` to honor `net.Conn::Write` semantics on the Go side
// (must write everything or return an error); single `write()` can
// silently short-write and corrupt streams that the caller assumes are
// fully written.
match run_with_cancel(
&close_token,
timeout_ms,
"failed to write tcp data plane",
wr.write_all(buf),
)
.await
{
Some(Ok(())) => total as std::ffi::c_int,
Some(Err(e)) => {
set_error_msg(&format!("failed to write tcp data plane: {}", e));
-1
}
None => -1,
}
})
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn data_plane_tcp_close(handle: u64) -> std::ffi::c_int {
let _data_plane_usage_guard = match enter_data_plane_operation() {
Some(guard) => guard,
None => return -1,
};
crate::data_plane_async::cancel_ops_for_handle(handle);
let Some((_, h)) = DATA_PLANE_HANDLES.remove_if(&handle, |_, e| {
matches!(e.resource, DataPlaneResource::Tcp(_))
}) else {
set_error_msg(if DATA_PLANE_HANDLES.contains_key(&handle) {
"handle is not a tcp stream"
} else {
"tcp stream handle not found"
});
return -1;
};
h.close_token.cancel();
if let DataPlaneResource::Tcp(halves) = h.resource {
// Best-effort half-close; if write half is in use, the in-flight call
// observes the cancel token and releases the lock shortly after.
h.runtime.spawn(async move {
if let Ok(mut wr) = halves.write.try_lock() {
let _ = wr.shutdown().await;
}
});
}
0
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn data_plane_tcp_listener_close(handle: u64) -> std::ffi::c_int {
let _data_plane_usage_guard = match enter_data_plane_operation() {
Some(guard) => guard,
None => return -1,
};
crate::data_plane_async::cancel_ops_for_handle(handle);
let Some((_, h)) = DATA_PLANE_HANDLES.remove_if(&handle, |_, e| {
matches!(e.resource, DataPlaneResource::TcpListener(_))
}) else {
set_error_msg(if DATA_PLANE_HANDLES.contains_key(&handle) {
"handle is not a tcp listener"
} else {
"tcp listener handle not found"
});
return -1;
};
h.close_token.cancel();
0
}
/// # Safety
/// Bind a UDP socket through an EasyTier instance data plane. Returns 0 on
/// failure. The local address actually bound (which may differ from the
/// requested port when `local_port == 0`) is written into `out_local_ip` /
/// `out_local_port`; the caller must release `*out_local_ip` via `free_string`.
#[cfg(feature = "ffi-dataplane")]
pub(crate) unsafe fn data_plane_udp_bind(
inst_name: *const std::ffi::c_char,
local_port: std::ffi::c_ushort,
timeout_ms: u64,
out_local_ip: *mut *const std::ffi::c_char,
out_local_port: *mut std::ffi::c_ushort,
) -> u64 {
let _data_plane_usage_guard = match enter_data_plane_operation() {
Some(guard) => guard,
None => return 0,
};
if out_local_ip.is_null() || out_local_port.is_null() {
set_error_msg("output pointer is null");
return 0;
}
let Some(inst_name) = (unsafe { cstr_to_string(inst_name, "inst_name") }) else {
return 0;
};
let Some(inst_id) = get_instance_id(&inst_name) else {
set_error_msg("instance not found");
return 0;
};
let deadline = std::time::Instant::now() + timeout_duration(timeout_ms);
let Some(runtime) = get_runtime_handle(&inst_id, deadline) else {
return 0;
};
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
let result =
runtime.block_on(INSTANCE_MANAGER.data_plane_udp_bind(&inst_id, local_port, remaining));
match result {
Ok(socket) => {
let local_addr = socket.local_addr();
let Some(local_ip) = into_ffi_ip_cstring(local_addr.ip()) else {
return 0;
};
let handle = insert_udp_socket_handle(inst_id, runtime, socket);
unsafe {
*out_local_ip = local_ip as *const std::ffi::c_char;
*out_local_port = local_addr.port();
}
handle
}
Err(e) => {
set_error_msg(&format!("failed to bind udp data plane: {}", e));
0
}
}
}
/// # Safety
/// Send a datagram through a UDP data-plane socket.
#[cfg(feature = "ffi-dataplane")]
pub(crate) unsafe fn data_plane_udp_send_to(
handle: u64,
dst_ip: *const std::ffi::c_char,
dst_port: std::ffi::c_ushort,
buf: *const std::ffi::c_uchar,
len: u32,
timeout_ms: u64,
) -> std::ffi::c_int {
let _data_plane_usage_guard = match enter_data_plane_operation() {
Some(guard) => guard,
None => return -1,
};
if buf.is_null() {
set_error_msg("buf is null");
return -1;
}
let Some(dst_ip) = (unsafe { cstr_to_string(dst_ip, "dst_ip") }) else {
return -1;
};
let Some(dst_addr) = parse_socket_addr(&dst_ip, dst_port) else {
return -1;
};
let Some((socket, runtime, close_token)) = get_udp_socket(handle) else {
return -1;
};
let total = len as usize;
// Safety: caller-owned buffer outlives this blocking call.
let buf = unsafe { std::slice::from_raw_parts(buf, total) };
runtime.block_on(async move {
match run_with_cancel(
&close_token,
timeout_ms,
"failed to send udp data plane",
socket.send_to(buf, dst_addr),
)
.await
{
Some(Ok(n)) => n as std::ffi::c_int,
Some(Err(e)) => {
set_error_msg(&format!("failed to send udp data plane: {}", e));
-1
}
None => -1,
}
})
}
/// # Safety
/// Receive a datagram from a UDP data-plane socket.
#[cfg(feature = "ffi-dataplane")]
pub(crate) unsafe fn data_plane_udp_recv_from(
handle: u64,
buf: *mut std::ffi::c_uchar,
len: u32,
out_ip: *mut *const std::ffi::c_char,
out_port: *mut std::ffi::c_ushort,
timeout_ms: u64,
) -> std::ffi::c_int {
let _data_plane_usage_guard = match enter_data_plane_operation() {
Some(guard) => guard,
None => return -1,
};
if buf.is_null() || out_ip.is_null() || out_port.is_null() {
set_error_msg("output pointer is null");
return -1;
}
let Some((socket, runtime, close_token)) = get_udp_socket(handle) else {
return -1;
};
let total = len as usize;
// Safety: caller-owned buffer outlives this blocking call.
let buf = unsafe { std::slice::from_raw_parts_mut(buf, total) };
let ret = runtime.block_on(run_with_cancel(
&close_token,
timeout_ms,
"udp data plane receive",
socket.recv_from(buf),
));
match ret {
Some(Ok((n, addr))) => {
// The returned ip pointer must be released by the caller via
// `free_string` (which calls `CString::from_raw`, matching
// `CString::into_raw` here).
let Some(ip_cstr) = into_ffi_ip_cstring(addr.ip()) else {
return -1;
};
unsafe {
*out_ip = ip_cstr as *const std::ffi::c_char;
*out_port = addr.port() as std::ffi::c_ushort;
}
n as std::ffi::c_int
}
Some(Err(e)) => {
set_error_msg(&format!("failed to receive udp data plane: {}", e));
-1
}
None => -1,
}
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn data_plane_udp_close(handle: u64) -> std::ffi::c_int {
let _data_plane_usage_guard = match enter_data_plane_operation() {
Some(guard) => guard,
None => return -1,
};
crate::data_plane_async::cancel_ops_for_handle(handle);
let Some((_, h)) = DATA_PLANE_HANDLES.remove_if(&handle, |_, e| {
matches!(e.resource, DataPlaneResource::Udp(_))
}) else {
set_error_msg(if DATA_PLANE_HANDLES.contains_key(&handle) {
"handle is not a udp socket"
} else {
"udp socket handle not found"
});
return -1;
};
h.close_token.cancel();
0
}
#[cfg(all(test, feature = "ffi-dataplane"))]
mod tests {
use super::*;
use std::{sync::mpsc, time::Duration};
#[test]
fn config_server_start_waits_for_data_plane_operation() {
let read_guard = DATA_PLANE_USAGE_LOCK.read().unwrap();
let (done_tx, done_rx) = mpsc::channel();
let waiter = std::thread::spawn(move || {
let _write_guard = lock_for_config_server_start().unwrap();
done_tx.send(()).unwrap();
});
assert!(done_rx.recv_timeout(Duration::from_millis(100)).is_err());
drop(read_guard);
done_rx.recv_timeout(Duration::from_secs(5)).unwrap();
waiter.join().unwrap();
}
#[test]
fn instance_cleanup_waits_for_data_plane_operation() {
let read_guard = DATA_PLANE_USAGE_LOCK.read().unwrap();
let instance_id = Uuid::new_v4();
let (done_tx, done_rx) = mpsc::channel();
let cleaner = std::thread::spawn(move || {
remove_data_plane_handles_by_instance_ids(&[instance_id]);
done_tx.send(()).unwrap();
});
assert!(done_rx.recv_timeout(Duration::from_millis(100)).is_err());
drop(read_guard);
done_rx.recv_timeout(Duration::from_secs(5)).unwrap();
cleaner.join().unwrap();
}
}
File diff suppressed because it is too large Load Diff
@@ -1,65 +0,0 @@
use std::{
cell::RefCell,
ffi::{CString, c_char},
};
thread_local! {
// # Thread Safety
// set_error_msg and get_error_msg must be called on the same thread to
// get correct error. And since `Handle::block_on` polls the top-level
// future on the calling thread, set_error_msg always runs on the same
// thread as the corresponding get_error_msg.
static ERROR_MSG: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
}
pub(crate) fn set_error_msg(msg: &str) {
ERROR_MSG.with(|cell| {
let mut buf = cell.borrow_mut();
buf.clear();
buf.extend_from_slice(msg.as_bytes());
});
}
fn thread_local_error_msg() -> Option<String> {
ERROR_MSG.with(|cell| {
let buf = cell.borrow();
if buf.is_empty() {
None
} else {
Some(String::from_utf8_lossy(&buf).into_owned())
}
})
}
pub(crate) unsafe fn get_error_msg(out: *mut *const c_char) {
let msg = match (
thread_local_error_msg(),
crate::config_server::last_callback_error(),
) {
(Some(error), Some(callback_error)) => Some(format!(
"{}; config server callback error: {}",
error, callback_error
)),
(Some(error), None) => Some(error),
(None, Some(callback_error)) => {
Some(format!("config server callback error: {}", callback_error))
}
(None, None) => None,
};
let cstr = msg.and_then(|msg| CString::new(msg).ok());
unsafe {
*out = match cstr {
Some(s) => s.into_raw() as *const c_char,
None => std::ptr::null(),
};
}
}
pub(crate) fn free_string(s: *const c_char) {
if s.is_null() {
return;
}
unsafe {
let _ = CString::from_raw(s as *mut c_char);
}
}
@@ -1,366 +0,0 @@
use std::ffi::{CString, c_char, c_int};
use easytier::common::config::{ConfigFileControl, ConfigLoader as _, TomlConfigLoader};
use crate::{
config_server::{
in_config_server_callback, remove_config_server_tracked_instance_ids,
wait_for_config_server_delivery,
},
data_plane::remove_data_plane_handles_by_instance_ids,
error::set_error_msg,
state::{
INSTANCE_MANAGER, INSTANCE_MUTATION_LOCK, INSTANCE_NAME_ID_MAP, instance_name_exists,
lock_remote_instance_mutation,
},
types::KeyValuePair,
};
/// # Safety
/// Set the tun fd
pub(crate) unsafe fn set_tun_fd(inst_name: *const c_char, fd: c_int) -> c_int {
let inst_name = unsafe {
assert!(!inst_name.is_null());
std::ffi::CStr::from_ptr(inst_name)
.to_string_lossy()
.into_owned()
};
if !INSTANCE_NAME_ID_MAP.contains_key(&inst_name) {
return -1;
}
let inst_id = *INSTANCE_NAME_ID_MAP
.get(&inst_name)
.as_ref()
.unwrap()
.value();
match INSTANCE_MANAGER.set_tun_fd(&inst_id, fd) {
Ok(_) => 0,
Err(_) => -1,
}
}
/// # Safety
/// Parse the config
pub(crate) unsafe fn parse_config(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int {
let cfg_str = unsafe {
assert!(!cfg_str.is_null());
std::ffi::CStr::from_ptr(cfg_str)
.to_string_lossy()
.into_owned()
};
if let Err(e) = TomlConfigLoader::new_from_str(&cfg_str) {
set_error_msg(&format!("failed to parse config: {:?}", e));
return -1;
}
0
}
/// # Safety
/// Run the network instance
pub(crate) unsafe fn run_network_instance(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int {
if in_config_server_callback() {
set_error_msg("cannot run network instance from config server callback");
return -1;
}
let cfg_str = unsafe {
assert!(!cfg_str.is_null());
std::ffi::CStr::from_ptr(cfg_str)
.to_string_lossy()
.into_owned()
};
let cfg = match TomlConfigLoader::new_from_str(&cfg_str) {
Ok(cfg) => cfg,
Err(e) => {
set_error_msg(&format!("failed to parse config: {}", e));
return -1;
}
};
let inst_name = cfg.get_inst_name();
wait_for_config_server_delivery();
let _remote_mutation_guard = lock_remote_instance_mutation();
let _mutation_guard = match INSTANCE_MUTATION_LOCK.lock() {
Ok(guard) => guard,
Err(err) => {
set_error_msg(&format!("failed to lock instance mutation: {}", err));
return -1;
}
};
if instance_name_exists(&inst_name) {
set_error_msg("instance already exists");
return -1;
}
let instance_id =
match INSTANCE_MANAGER.run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG) {
Ok(id) => id,
Err(e) => {
set_error_msg(&format!("failed to start instance: {}", e));
return -1;
}
};
INSTANCE_NAME_ID_MAP.insert(inst_name, instance_id);
0
}
unsafe fn parse_instance_names(
inst_names: *const *const c_char,
length: usize,
) -> Option<Vec<String>> {
if length == 0 {
return Some(Vec::new());
}
if inst_names.is_null() {
set_error_msg("inst_names is null");
return None;
}
let names = unsafe { std::slice::from_raw_parts(inst_names, length) };
let mut parsed = Vec::with_capacity(length);
for (index, &name) in names.iter().enumerate() {
if name.is_null() {
set_error_msg(&format!("inst_names[{}] is null", index));
return None;
}
parsed.push(
unsafe { std::ffi::CStr::from_ptr(name) }
.to_string_lossy()
.into_owned(),
);
}
Some(parsed)
}
/// # Safety
/// Retain the network instance
pub(crate) unsafe fn retain_network_instance(
inst_names: *const *const std::ffi::c_char,
length: usize,
) -> std::ffi::c_int {
if in_config_server_callback() {
set_error_msg("cannot retain network instances from config server callback");
return -1;
}
wait_for_config_server_delivery();
let _remote_mutation_guard = lock_remote_instance_mutation();
let _mutation_guard = match INSTANCE_MUTATION_LOCK.lock() {
Ok(guard) => guard,
Err(err) => {
set_error_msg(&format!("failed to lock instance mutation: {}", err));
return -1;
}
};
if length == 0 {
let removed_ids = INSTANCE_MANAGER.list_network_instance_ids();
if let Err(e) = INSTANCE_MANAGER.delete_network_instance(removed_ids.clone()) {
set_error_msg(&format!("failed to delete instances: {}", e));
return -1;
}
remove_config_server_tracked_instance_ids(&removed_ids);
remove_data_plane_handles_by_instance_ids(&removed_ids);
INSTANCE_NAME_ID_MAP.clear();
return 0;
}
let Some(inst_names) = (unsafe { parse_instance_names(inst_names, length) }) else {
return -1;
};
let removed_ids = INSTANCE_MANAGER
.list_network_instance_ids()
.into_iter()
.filter(|id| {
INSTANCE_MANAGER
.get_instance_name(id)
.is_none_or(|name| !inst_names.contains(&name))
})
.collect::<Vec<_>>();
if let Err(e) = INSTANCE_MANAGER.delete_network_instance(removed_ids.clone()) {
set_error_msg(&format!("failed to delete instances: {}", e));
return -1;
}
remove_config_server_tracked_instance_ids(&removed_ids);
remove_data_plane_handles_by_instance_ids(&removed_ids);
INSTANCE_NAME_ID_MAP.retain(|k, _| inst_names.contains(k));
0
}
/// # Safety
/// Delete named network instances.
pub(crate) unsafe fn delete_network_instance(
inst_names: *const *const std::ffi::c_char,
length: usize,
) -> std::ffi::c_int {
if in_config_server_callback() {
set_error_msg("cannot delete network instances from config server callback");
return -1;
}
wait_for_config_server_delivery();
let _remote_mutation_guard = lock_remote_instance_mutation();
let _mutation_guard = match INSTANCE_MUTATION_LOCK.lock() {
Ok(guard) => guard,
Err(err) => {
set_error_msg(&format!("failed to lock instance mutation: {}", err));
return -1;
}
};
if length == 0 {
return 0;
}
let Some(inst_names) = (unsafe { parse_instance_names(inst_names, length) }) else {
return -1;
};
let removed_ids = inst_names
.iter()
.filter_map(|name| INSTANCE_NAME_ID_MAP.get(name).map(|id| *id.value()))
.collect::<Vec<_>>();
if let Err(e) = INSTANCE_MANAGER.delete_network_instance(removed_ids.clone()) {
set_error_msg(&format!("failed to delete instances: {}", e));
return -1;
}
remove_config_server_tracked_instance_ids(&removed_ids);
remove_data_plane_handles_by_instance_ids(&removed_ids);
for name in inst_names {
INSTANCE_NAME_ID_MAP.remove(&name);
}
0
}
/// # Safety
/// Collect the network infos
pub(crate) unsafe fn collect_network_infos(
infos: *mut KeyValuePair,
max_length: usize,
) -> std::ffi::c_int {
if in_config_server_callback() {
set_error_msg("cannot collect network infos from config server callback");
return -1;
}
if max_length == 0 {
return 0;
}
let infos = unsafe {
assert!(!infos.is_null());
std::slice::from_raw_parts_mut(infos, max_length)
};
let collected_infos = match INSTANCE_MANAGER.collect_network_infos_sync() {
Ok(infos) => infos,
Err(e) => {
set_error_msg(&format!("failed to collect network infos: {}", e));
return -1;
}
};
let mut index = 0;
for (instance_id, value) in collected_infos.iter() {
if index >= max_length {
break;
}
let Some(key) = INSTANCE_MANAGER.get_instance_name(instance_id) else {
continue;
};
// convert value to json string
let value = match serde_json::to_string(&value) {
Ok(value) => value,
Err(e) => {
set_error_msg(&format!("failed to serialize instance info: {}", e));
return -1;
}
};
infos[index] = KeyValuePair {
key: std::ffi::CString::new(key).unwrap().into_raw(),
value: std::ffi::CString::new(value).unwrap().into_raw(),
};
index += 1;
}
index as std::ffi::c_int
}
/// # Safety
/// List the instance names and IDs known by the FFI instance manager.
pub(crate) unsafe fn list_instance(infos: *mut KeyValuePair, max_length: usize) -> std::ffi::c_int {
if in_config_server_callback() {
set_error_msg("cannot list instances from config server callback");
return -1;
}
if max_length == 0 {
return 0;
}
if infos.is_null() {
set_error_msg("infos is null");
return -1;
}
let infos = unsafe { std::slice::from_raw_parts_mut(infos, max_length) };
let mut instances = INSTANCE_MANAGER
.list_network_instance_ids()
.into_iter()
.filter_map(|id| {
INSTANCE_MANAGER
.get_instance_name(&id)
.map(|name| (name, id))
})
.collect::<Vec<_>>();
instances.sort_by(|(left_name, left_id), (right_name, right_id)| {
left_name
.cmp(right_name)
.then_with(|| left_id.to_string().cmp(&right_id.to_string()))
});
let encoded_instances = match instances
.into_iter()
.take(max_length)
.map(|(name, id)| {
let key = CString::new(name)
.map_err(|err| format!("failed to encode instance name: {}", err))?;
let value = CString::new(id.to_string())
.map_err(|err| format!("failed to encode instance id: {}", err))?;
Ok((key, value))
})
.collect::<Result<Vec<_>, String>>()
{
Ok(value) => value,
Err(err) => {
set_error_msg(&err);
return -1;
}
};
let count = encoded_instances.len();
for (index, (key, value)) in encoded_instances.into_iter().enumerate() {
infos[index] = KeyValuePair {
key: key.into_raw(),
value: value.into_raw(),
};
}
count as std::ffi::c_int
}
@@ -1,100 +0,0 @@
use std::ffi::{CString, c_char, c_int};
use crate::{
config_server::in_config_server_callback,
error::set_error_msg,
state::{ASYNC_RUNTIME, INSTANCE_MANAGER},
strings::{c_str_to_string, optional_c_str_to_string},
};
/// # Safety
/// See `crate::call_json_rpc`.
pub(crate) unsafe fn call_json_rpc(
service_name: *const c_char,
method_name: *const c_char,
domain_name: *const c_char,
payload_json: *const c_char,
out_response_json: *mut *const c_char,
) -> c_int {
if out_response_json.is_null() {
set_error_msg("out_response_json is null");
return -1;
}
unsafe {
*out_response_json = std::ptr::null();
}
if in_config_server_callback() {
set_error_msg("cannot call JSON RPC from config server callback");
return -1;
}
let service_name = match unsafe { c_str_to_string(service_name, "service_name") } {
Ok(value) => value,
Err(err) => {
set_error_msg(&err);
return -1;
}
};
let method_name = match unsafe { c_str_to_string(method_name, "method_name") } {
Ok(value) => value,
Err(err) => {
set_error_msg(&err);
return -1;
}
};
let domain_name = match unsafe { optional_c_str_to_string(domain_name, "domain_name") } {
Ok(value) => value,
Err(err) => {
set_error_msg(&err);
return -1;
}
};
let payload_json = match unsafe { c_str_to_string(payload_json, "payload_json") } {
Ok(value) => value,
Err(err) => {
set_error_msg(&err);
return -1;
}
};
let payload = match serde_json::from_str::<serde_json::Value>(&payload_json) {
Ok(value) => value,
Err(err) => {
set_error_msg(&format!("failed to parse payload_json: {}", err));
return -1;
}
};
let response = match ASYNC_RUNTIME.block_on(easytier::rpc_service::call_json_rpc(
&INSTANCE_MANAGER,
&service_name,
&method_name,
domain_name.as_deref(),
payload,
)) {
Ok(value) => value,
Err(err) => {
set_error_msg(&format!("RPC Error: {}", err));
return -1;
}
};
let response_json = match serde_json::to_string(&response) {
Ok(value) => value,
Err(err) => {
set_error_msg(&format!("failed to serialize RPC response: {}", err));
return -1;
}
};
let response_json = match CString::new(response_json) {
Ok(value) => value,
Err(err) => {
set_error_msg(&format!("failed to allocate RPC response: {}", err));
return -1;
}
};
unsafe {
*out_response_json = response_json.into_raw();
}
0
}
File diff suppressed because it is too large Load Diff
@@ -1,54 +0,0 @@
use std::sync::{Arc, Mutex};
use dashmap::DashMap;
use easytier::instance_manager::NetworkInstanceManager;
use tokio::runtime::{Builder, Runtime};
use uuid::Uuid;
pub(crate) static INSTANCE_NAME_ID_MAP: once_cell::sync::Lazy<DashMap<String, Uuid>> =
once_cell::sync::Lazy::new(DashMap::new);
pub(crate) static INSTANCE_MANAGER: once_cell::sync::Lazy<Arc<NetworkInstanceManager>> =
once_cell::sync::Lazy::new(|| Arc::new(NetworkInstanceManager::new()));
pub(crate) 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-ffi")
});
pub(crate) static INSTANCE_MUTATION_LOCK: once_cell::sync::Lazy<Mutex<()>> =
once_cell::sync::Lazy::new(|| Mutex::new(()));
pub(crate) fn remove_instance_name_ids(ids: &[Uuid]) {
if ids.is_empty() {
return;
}
INSTANCE_NAME_ID_MAP.retain(|_, instance_id| !ids.contains(instance_id));
}
pub(crate) fn lock_remote_instance_mutation() -> tokio::sync::OwnedMutexGuard<()> {
INSTANCE_MANAGER
.remote_mutation_lock()
.blocking_lock_owned()
}
pub(crate) fn instance_name_exists(inst_name: &str) -> bool {
find_instance_id_by_name(inst_name).is_some()
}
pub(crate) fn find_instance_id_by_name(inst_name: &str) -> Option<Uuid> {
INSTANCE_NAME_ID_MAP
.get(inst_name)
.map(|id| *id)
.or_else(|| {
INSTANCE_MANAGER
.list_network_instance_ids()
.into_iter()
.find(|id| {
INSTANCE_MANAGER
.get_instance_name(id)
.is_some_and(|name| name == inst_name)
})
})
}
@@ -1,23 +0,0 @@
use std::ffi::{CStr, c_char};
pub(crate) unsafe fn c_str_to_string(ptr: *const c_char, name: &str) -> Result<String, String> {
if ptr.is_null() {
return Err(format!("{} is null", name));
}
unsafe { CStr::from_ptr(ptr) }
.to_str()
.map(|value| value.to_string())
.map_err(|err| format!("{} is not valid UTF-8: {}", name, err))
}
pub(crate) unsafe fn optional_c_str_to_string(
ptr: *const c_char,
name: &str,
) -> Result<Option<String>, String> {
if ptr.is_null() {
return Ok(None);
}
unsafe { c_str_to_string(ptr, name) }.map(Some)
}
-766
View File
@@ -1,766 +0,0 @@
use crate::{
config_server::{
ConfigServerCallbackScope, ManagedConfigServerClientHooks, set_active_for_test,
},
state::{
INSTANCE_MANAGER, INSTANCE_NAME_ID_MAP, find_instance_id_by_name,
lock_remote_instance_mutation, remove_instance_name_ids,
},
*,
};
use easytier::{
common::config::{ConfigFileControl, ConfigLoader as _, TomlConfigLoader},
web_client::WebClientHooks,
};
use serde_json::Value;
use std::{
collections::HashSet,
ffi::{CStr, CString, c_char, c_void},
sync::{Mutex, mpsc},
time::Duration,
};
use uuid::Uuid;
#[test]
fn test_parse_config() {
let cfg_str = r#"
inst_name = "test"
network = "test_network"
"#;
let cstr = std::ffi::CString::new(cfg_str).unwrap();
unsafe {
assert_eq!(parse_config(cstr.as_ptr()), 0);
}
}
#[test]
fn test_run_network_instance() {
let cfg_str = r#"
inst_name = "test"
network = "test_network"
"#;
let cstr = std::ffi::CString::new(cfg_str).unwrap();
unsafe {
assert_eq!(run_network_instance(cstr.as_ptr()), 0);
}
}
#[test]
fn get_error_msg_returns_config_server_callback_error() {
let hooks = ManagedConfigServerClientHooks::new(None, std::ptr::null_mut());
let callback_error = format!("callback delivery failed {}", Uuid::new_v4());
crate::config_server::clear_last_callback_error();
hooks.note_callback_error(callback_error.clone());
unsafe {
let mut error_ptr: *const c_char = std::ptr::null();
get_error_msg(&mut error_ptr);
assert!(!error_ptr.is_null());
let error_msg = CStr::from_ptr(error_ptr).to_string_lossy().into_owned();
free_string(error_ptr);
assert!(error_msg.contains(&callback_error));
}
crate::config_server::clear_last_callback_error();
}
unsafe extern "C" fn record_config_server_event(event_json: *const c_char, user_data: *mut c_void) {
let events = unsafe { &*(user_data as *const Mutex<Vec<String>>) };
events.lock().unwrap().push(
unsafe { CStr::from_ptr(event_json) }
.to_string_lossy()
.into_owned(),
);
}
fn take_last_error() -> Option<String> {
unsafe {
let mut error_ptr: *const c_char = std::ptr::null();
get_error_msg(&mut error_ptr);
if error_ptr.is_null() {
None
} else {
let error = CStr::from_ptr(error_ptr).to_string_lossy().into_owned();
free_string(error_ptr);
Some(error)
}
}
}
fn free_key_value_pairs(infos: &[KeyValuePair]) {
for info in infos {
free_string(info.key);
free_string(info.value);
}
}
#[test]
fn list_instance_returns_instance_names_and_ids() {
let instance_id = Uuid::new_v4();
let instance_name = format!("list-instance-{}", instance_id);
let cfg = TomlConfigLoader::default();
cfg.set_id(instance_id);
cfg.set_inst_name(instance_name.clone());
INSTANCE_MANAGER
.run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG)
.unwrap();
INSTANCE_NAME_ID_MAP.insert(instance_name.clone(), instance_id);
let mut infos = vec![
KeyValuePair {
key: std::ptr::null(),
value: std::ptr::null(),
};
16
];
let count = unsafe { list_instance(infos.as_mut_ptr(), infos.len()) };
assert!(count > 0);
let mut found = false;
for info in infos.iter().take(count as usize) {
let key = unsafe { CStr::from_ptr(info.key) }.to_string_lossy();
let value = unsafe { CStr::from_ptr(info.value) }.to_string_lossy();
if key == instance_name {
assert_eq!(value, instance_id.to_string());
found = true;
}
}
free_key_value_pairs(&infos[..count as usize]);
INSTANCE_MANAGER
.delete_network_instance(vec![instance_id])
.unwrap();
remove_instance_name_ids(&[instance_id]);
assert!(found);
}
#[test]
fn list_instance_allows_zero_length() {
assert_eq!(unsafe { list_instance(std::ptr::null_mut(), 0) }, 0);
}
#[test]
fn list_instance_rejects_null_output_pointer() {
assert_eq!(unsafe { list_instance(std::ptr::null_mut(), 1) }, -1);
assert!(take_last_error().unwrap().contains("infos is null"));
}
#[test]
fn call_json_rpc_returns_logger_response() {
let service = CString::new("api.logger.LoggerRpcService").unwrap();
let method = CString::new("get_logger_config").unwrap();
let payload = CString::new("{}").unwrap();
let mut response_ptr: *const c_char = std::ptr::null();
assert_eq!(
unsafe {
call_json_rpc(
service.as_ptr(),
method.as_ptr(),
std::ptr::null(),
payload.as_ptr(),
&mut response_ptr,
)
},
0
);
assert!(!response_ptr.is_null());
let response = unsafe { CStr::from_ptr(response_ptr) }
.to_string_lossy()
.into_owned();
free_string(response_ptr);
let response: Value = serde_json::from_str(&response).unwrap();
assert!(response.get("level").is_some());
}
#[test]
fn call_json_rpc_rejects_instance_management_service() {
let service = CString::new("api.manage.WebClientService").unwrap();
let method = CString::new("list_network_instance").unwrap();
let payload = CString::new("{}").unwrap();
let mut response_ptr: *const c_char = std::ptr::null();
assert_eq!(
unsafe {
call_json_rpc(
service.as_ptr(),
method.as_ptr(),
std::ptr::null(),
payload.as_ptr(),
&mut response_ptr,
)
},
-1
);
assert!(response_ptr.is_null());
assert!(take_last_error().unwrap().contains("not exposed"));
}
#[test]
fn call_json_rpc_rejects_malformed_payload_json() {
let service = CString::new("api.logger.LoggerRpcService").unwrap();
let method = CString::new("get_logger_config").unwrap();
let payload = CString::new("{").unwrap();
let mut response_ptr: *const c_char = std::ptr::null();
assert_eq!(
unsafe {
call_json_rpc(
service.as_ptr(),
method.as_ptr(),
std::ptr::null(),
payload.as_ptr(),
&mut response_ptr,
)
},
-1
);
assert!(response_ptr.is_null());
assert!(
take_last_error()
.unwrap()
.contains("failed to parse payload_json")
);
}
#[test]
fn call_json_rpc_rejects_null_output_pointer() {
let service = CString::new("api.logger.LoggerRpcService").unwrap();
let method = CString::new("get_logger_config").unwrap();
let payload = CString::new("{}").unwrap();
assert_eq!(
unsafe {
call_json_rpc(
service.as_ptr(),
method.as_ptr(),
std::ptr::null(),
payload.as_ptr(),
std::ptr::null_mut(),
)
},
-1
);
assert!(
take_last_error()
.unwrap()
.contains("out_response_json is null")
);
}
#[tokio::test]
async fn config_server_hooks_emit_run_event() {
let events: Mutex<Vec<String>> = Mutex::new(Vec::new());
let hooks = ManagedConfigServerClientHooks::new(
Some(record_config_server_event),
&events as *const _ as *mut c_void,
);
let instance_id = Uuid::new_v4();
let cfg = TomlConfigLoader::default();
cfg.set_id(instance_id);
let inst_name = format!("test-{}", instance_id);
cfg.set_inst_name(inst_name.clone());
hooks.pre_run_network_instance(&cfg).await.unwrap();
INSTANCE_MANAGER
.run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG)
.unwrap();
hooks.post_run_network_instance(&instance_id).await.unwrap();
let duplicate_cfg = TomlConfigLoader::default();
duplicate_cfg.set_inst_name(inst_name);
duplicate_cfg.set_id(Uuid::new_v4());
assert!(
hooks
.pre_run_network_instance(&duplicate_cfg)
.await
.is_err()
);
assert_eq!(hooks.tracked_instance_ids(), vec![instance_id]);
let events = events.lock().unwrap();
assert_eq!(events.len(), 1);
let event: Value = serde_json::from_str(&events[0]).unwrap();
assert_eq!(event["event"], "run_network_instance");
assert_eq!(event["success"], true);
assert_eq!(event["instance_id"], instance_id.to_string());
assert!(event["error"].is_null());
INSTANCE_MANAGER
.delete_network_instance(vec![instance_id])
.unwrap();
remove_instance_name_ids(&[instance_id]);
}
#[tokio::test]
async fn config_server_hooks_emit_delete_events_for_tracked_instances() {
let events: Mutex<Vec<String>> = Mutex::new(Vec::new());
let hooks = ManagedConfigServerClientHooks::new(
Some(record_config_server_event),
&events as *const _ as *mut c_void,
);
let instance_id_1 = Uuid::new_v4();
let instance_id_2 = Uuid::new_v4();
let unknown_instance_id = Uuid::new_v4();
for id in [instance_id_1, instance_id_2] {
let cfg = TomlConfigLoader::default();
cfg.set_id(id);
cfg.set_inst_name(format!("test-{}", id));
hooks.pre_run_network_instance(&cfg).await.unwrap();
INSTANCE_MANAGER
.run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG)
.unwrap();
}
hooks
.post_run_network_instance(&instance_id_1)
.await
.unwrap();
hooks
.post_run_network_instance(&instance_id_2)
.await
.unwrap();
events.lock().unwrap().clear();
hooks
.post_remove_network_instances(&[instance_id_1, unknown_instance_id, instance_id_2])
.await
.unwrap();
assert!(hooks.tracked_instance_ids().is_empty());
let events = events.lock().unwrap();
assert_eq!(events.len(), 2);
let event_ids = events
.iter()
.map(|event| {
let event: Value = serde_json::from_str(event).unwrap();
assert_eq!(event["event"], "delete_network_instance");
assert_eq!(event["success"], true);
assert!(event["error"].is_null());
event["instance_id"].as_str().unwrap().to_string()
})
.collect::<HashSet<_>>();
assert_eq!(
event_ids,
HashSet::from([instance_id_1.to_string(), instance_id_2.to_string()])
);
INSTANCE_MANAGER
.delete_network_instance(vec![instance_id_1, instance_id_2])
.unwrap();
remove_instance_name_ids(&[instance_id_1, instance_id_2]);
}
#[tokio::test]
async fn config_server_hooks_remove_untracked_name_mapping_without_event() {
let events: Mutex<Vec<String>> = Mutex::new(Vec::new());
let hooks = ManagedConfigServerClientHooks::new(
Some(record_config_server_event),
&events as *const _ as *mut c_void,
);
let local_id = Uuid::new_v4();
let inst_name = format!("local-{}", local_id);
INSTANCE_NAME_ID_MAP.insert(inst_name.clone(), local_id);
hooks
.post_remove_network_instances(&[local_id])
.await
.unwrap();
assert!(INSTANCE_NAME_ID_MAP.get(&inst_name).is_none());
assert!(events.lock().unwrap().is_empty());
}
#[tokio::test]
async fn config_server_hooks_reject_duplicate_instance_name() {
let hooks = ManagedConfigServerClientHooks::new(None, std::ptr::null_mut());
let inst_name = format!("test-{}", Uuid::new_v4());
let existing_id = Uuid::new_v4();
let new_id = Uuid::new_v4();
INSTANCE_NAME_ID_MAP.insert(inst_name.clone(), existing_id);
let cfg = TomlConfigLoader::default();
cfg.set_inst_name(inst_name.clone());
cfg.set_id(new_id);
assert!(hooks.pre_run_network_instance(&cfg).await.is_err());
assert_eq!(*INSTANCE_NAME_ID_MAP.get(&inst_name).unwrap(), existing_id);
INSTANCE_NAME_ID_MAP.remove(&inst_name);
}
#[tokio::test]
async fn config_server_hooks_remove_overwritten_id_before_duplicate_name_error() {
let events: Mutex<Vec<String>> = Mutex::new(Vec::new());
let hooks = ManagedConfigServerClientHooks::new(
Some(record_config_server_event),
&events as *const _ as *mut c_void,
);
let old_name = format!("old-{}", Uuid::new_v4());
let duplicate_name = format!("duplicate-{}", Uuid::new_v4());
let overwritten_id = Uuid::new_v4();
let duplicate_id = Uuid::new_v4();
hooks.instance_ids.lock().unwrap().insert(overwritten_id);
INSTANCE_NAME_ID_MAP.insert(old_name.clone(), overwritten_id);
INSTANCE_NAME_ID_MAP.insert(duplicate_name.clone(), duplicate_id);
hooks
.post_remove_network_instances(&[overwritten_id])
.await
.unwrap();
let cfg = TomlConfigLoader::default();
cfg.set_inst_name(duplicate_name.clone());
cfg.set_id(overwritten_id);
assert!(hooks.pre_run_network_instance(&cfg).await.is_err());
assert!(hooks.tracked_instance_ids().is_empty());
assert!(INSTANCE_NAME_ID_MAP.get(&old_name).is_none());
assert_eq!(
*INSTANCE_NAME_ID_MAP.get(&duplicate_name).unwrap(),
duplicate_id
);
assert_eq!(events.lock().unwrap().len(), 1);
INSTANCE_NAME_ID_MAP.remove(&duplicate_name);
}
#[tokio::test]
async fn config_server_hooks_remove_tracked_state_before_overwrite_retry() {
let hooks = ManagedConfigServerClientHooks::new(None, std::ptr::null_mut());
let inst_name = format!("test-{}", Uuid::new_v4());
let instance_id = Uuid::new_v4();
hooks.instance_ids.lock().unwrap().insert(instance_id);
INSTANCE_NAME_ID_MAP.insert(inst_name.clone(), instance_id);
let cfg = TomlConfigLoader::default();
cfg.set_inst_name(inst_name.clone());
cfg.set_id(instance_id);
hooks
.post_remove_network_instances(&[instance_id])
.await
.unwrap();
hooks.pre_run_network_instance(&cfg).await.unwrap();
assert!(hooks.tracked_instance_ids().is_empty());
assert!(INSTANCE_NAME_ID_MAP.get(&inst_name).is_none());
}
#[tokio::test]
async fn config_server_hooks_reject_post_run_after_external_delete() {
let hooks = ManagedConfigServerClientHooks::new(None, std::ptr::null_mut());
let instance_id = Uuid::new_v4();
let cfg = TomlConfigLoader::default();
cfg.set_id(instance_id);
cfg.set_inst_name(format!("test-{}", instance_id));
hooks.pre_run_network_instance(&cfg).await.unwrap();
INSTANCE_MANAGER
.run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG)
.unwrap();
INSTANCE_MANAGER
.delete_network_instance(vec![instance_id])
.unwrap();
assert!(hooks.post_run_network_instance(&instance_id).await.is_err());
}
#[test]
fn find_instance_id_by_name_resolves_uncommitted_manager_instance_name() {
let instance_id = Uuid::new_v4();
let inst_name = format!("test-{}", instance_id);
let cfg = TomlConfigLoader::default();
cfg.set_id(instance_id);
cfg.set_inst_name(inst_name.clone());
INSTANCE_MANAGER
.run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG)
.unwrap();
assert_eq!(find_instance_id_by_name(&inst_name), Some(instance_id));
INSTANCE_MANAGER
.delete_network_instance(vec![instance_id])
.unwrap();
remove_instance_name_ids(&[instance_id]);
}
#[test]
fn delete_network_instance_removes_only_named_instances() {
let keep_id = Uuid::new_v4();
let delete_id = Uuid::new_v4();
let keep_name = format!("keep-{}", keep_id);
let delete_name = format!("delete-{}", delete_id);
for (id, name) in [
(keep_id, keep_name.clone()),
(delete_id, delete_name.clone()),
] {
let cfg = TomlConfigLoader::default();
cfg.set_id(id);
cfg.set_inst_name(name.clone());
INSTANCE_MANAGER
.run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG)
.unwrap();
INSTANCE_NAME_ID_MAP.insert(name, id);
}
let delete_name = CString::new(delete_name.clone()).unwrap();
let inst_names = [delete_name.as_ptr()];
assert_eq!(
unsafe { delete_network_instance(inst_names.as_ptr(), inst_names.len()) },
0
);
assert_eq!(find_instance_id_by_name(&keep_name), Some(keep_id));
assert!(find_instance_id_by_name(delete_name.to_str().unwrap()).is_none());
INSTANCE_MANAGER
.delete_network_instance(vec![keep_id])
.unwrap();
remove_instance_name_ids(&[keep_id]);
}
#[test]
fn retain_and_delete_network_instance_reject_invalid_name_pointers() {
assert_eq!(unsafe { retain_network_instance(std::ptr::null(), 1) }, -1);
assert_eq!(unsafe { delete_network_instance(std::ptr::null(), 1) }, -1);
let inst_names = [std::ptr::null()];
assert_eq!(
unsafe { retain_network_instance(inst_names.as_ptr(), inst_names.len()) },
-1
);
assert_eq!(
unsafe { delete_network_instance(inst_names.as_ptr(), inst_names.len()) },
-1
);
}
#[test]
fn ffi_remote_mutation_lock_uses_manager_lock() {
let manager_guard = INSTANCE_MANAGER
.remote_mutation_lock()
.blocking_lock_owned();
let (done_tx, done_rx) = mpsc::channel();
let waiter = std::thread::spawn(move || {
let _ffi_guard = lock_remote_instance_mutation();
done_tx.send(()).unwrap();
});
assert!(done_rx.recv_timeout(Duration::from_millis(100)).is_err());
drop(manager_guard);
done_rx.recv_timeout(Duration::from_secs(5)).unwrap();
waiter.join().unwrap();
}
#[tokio::test]
async fn config_server_hooks_suppress_late_run_events_while_stopping() {
let events: Mutex<Vec<String>> = Mutex::new(Vec::new());
let hooks = ManagedConfigServerClientHooks::new(
Some(record_config_server_event),
&events as *const _ as *mut c_void,
);
hooks.start_stopping();
hooks
.post_run_network_instance(&Uuid::new_v4())
.await
.unwrap();
assert!(hooks.tracked_instance_ids().is_empty());
assert!(events.lock().unwrap().is_empty());
}
#[test]
fn config_server_callback_context_rejects_nested_blocking_ffi_calls() {
let _callback_scope = ConfigServerCallbackScope::enter();
assert_eq!(is_config_server_client_connected(), 0);
let service = CString::new("api.logger.LoggerRpcService").unwrap();
let method = CString::new("get_logger_config").unwrap();
let payload = CString::new("{}").unwrap();
let mut response_ptr: *const c_char = std::ptr::null();
assert_eq!(
unsafe {
call_json_rpc(
service.as_ptr(),
method.as_ptr(),
std::ptr::null(),
payload.as_ptr(),
&mut response_ptr,
)
},
-1
);
assert!(response_ptr.is_null());
assert_eq!(
unsafe { collect_network_infos(std::ptr::null_mut(), 0) },
-1
);
assert_eq!(unsafe { list_instance(std::ptr::null_mut(), 0) }, -1);
let cfg = CString::new("inst_name = \"callback-test\"\nlisteners = []").unwrap();
assert_eq!(unsafe { run_network_instance(cfg.as_ptr()) }, -1);
assert_eq!(unsafe { retain_network_instance(std::ptr::null(), 0) }, -1);
assert_eq!(unsafe { delete_network_instance(std::ptr::null(), 0) }, -1);
let url = CString::new("ring://test/token").unwrap();
let machine_id = CString::new("test-machine").unwrap();
assert_eq!(
unsafe {
start_config_server_client(
url.as_ptr(),
std::ptr::null(),
machine_id.as_ptr(),
false,
None,
std::ptr::null_mut(),
)
},
-1
);
assert_eq!(stop_config_server_client(), -1);
#[cfg(feature = "ffi-dataplane")]
{
assert_eq!(
unsafe {
data_plane_tcp_connect(
std::ptr::null(),
std::ptr::null(),
0,
0,
std::ptr::null_mut(),
std::ptr::null_mut(),
)
},
0
);
assert_eq!(
unsafe {
data_plane_tcp_bind(
std::ptr::null(),
0,
0,
std::ptr::null_mut(),
std::ptr::null_mut(),
)
},
0
);
assert_eq!(
unsafe {
data_plane_tcp_accept(
0,
0,
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
)
},
0
);
assert_eq!(
unsafe { data_plane_tcp_read(0, std::ptr::null_mut(), 0, 0) },
-1
);
assert_eq!(
unsafe { data_plane_tcp_write(0, std::ptr::null(), 0, 0) },
-1
);
assert_eq!(data_plane_tcp_close(0), -1);
assert_eq!(data_plane_tcp_listener_close(0), -1);
assert_eq!(
unsafe {
data_plane_udp_bind(
std::ptr::null(),
0,
0,
std::ptr::null_mut(),
std::ptr::null_mut(),
)
},
0
);
assert_eq!(
unsafe { data_plane_udp_send_to(0, std::ptr::null(), 0, std::ptr::null(), 0, 0) },
-1
);
assert_eq!(
unsafe {
data_plane_udp_recv_from(
0,
std::ptr::null_mut(),
0,
std::ptr::null_mut(),
std::ptr::null_mut(),
0,
)
},
-1
);
assert_eq!(data_plane_udp_close(0), -1);
assert_eq!(data_plane_async_op_status(0), -2);
assert_eq!(data_plane_async_op_wait(0, 0), -2);
assert_eq!(data_plane_async_op_cancel(0), -2);
assert_eq!(data_plane_async_op_free(0), -2);
data_plane_free_bytes(std::ptr::null(), 0);
assert_eq!(
unsafe { data_plane_tcp_connect_start(std::ptr::null(), std::ptr::null(), 0, 0) },
0
);
assert_eq!(
unsafe { data_plane_tcp_bind_start(std::ptr::null(), 0, 0) },
0
);
assert_eq!(unsafe { data_plane_tcp_accept_start(0, 0) }, 0);
assert_eq!(unsafe { data_plane_tcp_read_start(0, 0, 0) }, 0);
assert_eq!(
unsafe { data_plane_tcp_write_start(0, std::ptr::null(), 0, 0) },
0
);
assert_eq!(
unsafe { data_plane_udp_bind_start(std::ptr::null(), 0, 0) },
0
);
assert_eq!(
unsafe { data_plane_udp_send_to_start(0, std::ptr::null(), 0, std::ptr::null(), 0, 0) },
0
);
assert_eq!(unsafe { data_plane_udp_recv_from_start(0, 0, 0) }, 0);
}
}
#[cfg(feature = "ffi-dataplane")]
#[test]
fn active_config_server_rejects_data_plane() {
set_active_for_test(true);
assert_eq!(
unsafe {
data_plane_tcp_connect(
std::ptr::null(),
std::ptr::null(),
0,
0,
std::ptr::null_mut(),
std::ptr::null_mut(),
)
},
0
);
assert_eq!(
unsafe { data_plane_tcp_read(0, std::ptr::null_mut(), 0, 0) },
-1
);
assert_eq!(
unsafe { data_plane_tcp_connect_start(std::ptr::null(), std::ptr::null(), 0, 0) },
0
);
assert_eq!(unsafe { data_plane_tcp_read_start(0, 0, 0) }, 0);
set_active_for_test(false);
}
#[cfg(feature = "ffi-dataplane")]
#[test]
fn async_op_invalid_handle_helpers_are_stable() {
assert_eq!(data_plane_async_op_status(u64::MAX), -2);
assert_eq!(data_plane_async_op_wait(u64::MAX, 1), -2);
assert_eq!(data_plane_async_op_cancel(u64::MAX), -2);
assert_eq!(data_plane_async_op_free(u64::MAX), -2);
data_plane_free_bytes(std::ptr::null(), 0);
}
@@ -1,10 +0,0 @@
use std::ffi::{c_char, c_void};
#[repr(C)]
#[derive(Clone, Copy)]
pub struct KeyValuePair {
pub key: *const c_char,
pub value: *const c_char,
}
pub type ConfigServerEventCallback = Option<unsafe extern "C" fn(*const c_char, *mut c_void)>;
@@ -99,7 +99,7 @@ while true; do
# 启动后的扫尾工作
if pgrep -f "${EASYTIER}" >/dev/null; then
if ! ip rule show | grep -qE '^[0-9]+:[[:space:]]+from all lookup main$'; then
if ! ip rule show | grep -q "lookup main"; then
ip rule add from all lookup main
fi
@@ -109,4 +109,4 @@ while true; do
fi
sleep 10s
done
done
+89 -166
View File
@@ -156,17 +156,6 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "async-lock"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
"event-listener",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-recursion"
version = "1.1.1"
@@ -666,15 +655,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "constant_time_eq"
version = "0.3.1"
@@ -1201,6 +1181,7 @@ dependencies = [
"derivative",
"derive_builder",
"derive_more",
"easytier-rpc-build",
"encoding",
"flume",
"forwarded-header-value",
@@ -1224,7 +1205,6 @@ dependencies = [
"itertools 0.14.0",
"kcp-sys",
"machine-uid",
"moka",
"multimap",
"natpmp",
"netlink-packet-core",
@@ -1237,22 +1217,20 @@ dependencies = [
"ordered_hash_map",
"parking_lot",
"paste",
"pbjson",
"pbjson-build",
"percent-encoding",
"petgraph",
"petgraph 0.8.2",
"pin-project-lite",
"pnet",
"prefix-trie",
"proc-macro2",
"prost 0.14.3",
"prost",
"prost-build",
"prost-reflect 0.16.4",
"prost-reflect",
"prost-reflect-build",
"prost-wkt",
"prost-wkt-build",
"prost-wkt-types",
"quinn",
"quinn-plaintext",
"quote",
"rand 0.8.5",
"rcgen",
"regex",
@@ -1285,6 +1263,7 @@ dependencies = [
"tokio-util",
"tokio-websockets",
"toml",
"tonic-build",
"tracing",
"tracing-subscriber",
"tun-easytier",
@@ -1318,8 +1297,9 @@ dependencies = [
"napi-build-ohos",
"napi-derive-ohos",
"napi-ohos",
"ohos-hilog-binding",
"once_cell",
"prost-reflect 0.14.7",
"prost-reflect",
"rusqlite",
"serde",
"serde_json",
@@ -1331,6 +1311,14 @@ dependencies = [
"uuid",
]
[[package]]
name = "easytier-rpc-build"
version = "0.1.0"
dependencies = [
"heck 0.5.0",
"prost-build",
]
[[package]]
name = "either"
version = "1.15.0"
@@ -1481,27 +1469,6 @@ dependencies = [
"arrayvec",
]
[[package]]
name = "event-listener"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
dependencies = [
"concurrent-queue",
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
@@ -2594,7 +2561,7 @@ dependencies = [
[[package]]
name = "kcp-sys"
version = "0.1.0"
source = "git+https://github.com/EasyTier/kcp-sys?rev=d7427c22d764deb1860a7d37acc446ed5033464c#d7427c22d764deb1860a7d37acc446ed5033464c"
source = "git+https://github.com/EasyTier/kcp-sys?rev=94964794caaed5d388463137da59b97499619e5f#94964794caaed5d388463137da59b97499619e5f"
dependencies = [
"anyhow",
"auto_impl",
@@ -2850,12 +2817,9 @@ version = "0.12.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9321642ca94a4282428e6ea4af8cc2ca4eac48ac7a6a4ea8f33f76d0ce70926"
dependencies = [
"async-lock",
"crossbeam-channel",
"crossbeam-epoch",
"crossbeam-utils",
"event-listener",
"futures-util",
"loom",
"parking_lot",
"portable-atomic",
@@ -3164,6 +3128,22 @@ dependencies = [
"libc",
]
[[package]]
name = "ohos-hilog-binding"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "860d1e3c2c5e3217d819a16c815d2d4dcbc7610285d2612d08745a29c353a503"
dependencies = [
"libc",
"ohos-hilogs-sys",
]
[[package]]
name = "ohos-hilogs-sys"
version = "0.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed07615005d0f8d7bcf901f89c8ff4870666a9bdb00382f588af383f40c160b7"
[[package]]
name = "once_cell"
version = "1.21.3"
@@ -3256,12 +3236,6 @@ dependencies = [
"unicode-width 0.1.11",
]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "parking_lot"
version = "0.12.4"
@@ -3291,28 +3265,6 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pbjson"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8edd1efdd8ab23ba9cb9ace3d9987a72663d5d7c9f74fa00b51d6213645cf6c"
dependencies = [
"base64 0.22.1",
"serde",
]
[[package]]
name = "pbjson-build"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ed4d5c6ae95e08ac768883c8401cf0e8deb4e6e1d6a4e1fd3d2ec4f0ec63200"
dependencies = [
"heck 0.5.0",
"itertools 0.14.0",
"prost 0.14.3",
"prost-types 0.14.3",
]
[[package]]
name = "pbkdf2"
version = "0.12.2"
@@ -3339,6 +3291,16 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petgraph"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772"
dependencies = [
"fixedbitset",
"indexmap",
]
[[package]]
name = "petgraph"
version = "0.8.2"
@@ -3626,33 +3588,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
dependencies = [
"bytes",
"prost-derive 0.13.5",
]
[[package]]
name = "prost"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568"
dependencies = [
"bytes",
"prost-derive 0.14.3",
"prost-derive",
]
[[package]]
name = "prost-build"
version = "0.14.3"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7"
checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf"
dependencies = [
"heck 0.5.0",
"itertools 0.14.0",
"log",
"multimap",
"petgraph",
"once_cell",
"petgraph 0.7.1",
"prettyplease",
"prost 0.14.3",
"prost-types 0.14.3",
"prost",
"prost-types",
"regex",
"syn 2.0.106",
"tempfile",
@@ -3671,19 +3624,6 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "prost-derive"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b"
dependencies = [
"anyhow",
"itertools 0.14.0",
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "prost-reflect"
version = "0.14.7"
@@ -3691,30 +3631,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5edd582b62f5cde844716e66d92565d7faf7ab1445c8cebce6e00fba83ddb2"
dependencies = [
"once_cell",
"prost 0.13.5",
"prost-reflect-derive 0.14.0",
"prost-types 0.13.5",
]
[[package]]
name = "prost-reflect"
version = "0.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "590aa145fee8f7a26b5a6055365e7c5e89a5c1caae9869de76ec0ee73181a2f9"
dependencies = [
"prost 0.14.3",
"prost-reflect-derive 0.16.0",
"prost-types 0.14.3",
"prost",
"prost-reflect-derive",
"prost-types",
]
[[package]]
name = "prost-reflect-build"
version = "0.16.0"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8214ae2c30bbac390db0134d08300e770ef89b6d4e5abf855e8d300eded87e28"
checksum = "50e2537231d94dd2778920c2ada37dd9eb1ac0325bb3ee3ee651bd44c1134123"
dependencies = [
"prost-build",
"prost-reflect 0.16.4",
"prost-reflect",
]
[[package]]
@@ -3728,44 +3657,24 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "prost-reflect-derive"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b6d90e29fa6c0d13c2c19ba5e4b3fb0efbf5975d27bcf4e260b7b15455bcabe"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "prost-types"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16"
dependencies = [
"prost 0.13.5",
]
[[package]]
name = "prost-types"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7"
dependencies = [
"prost 0.14.3",
"prost",
]
[[package]]
name = "prost-wkt"
version = "0.7.1"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd3de5e9c9e84fcb5efa204b8e283d23e615a8bc8c777bf1d6622bb01dc61445"
checksum = "497e1e938f0c09ef9cabe1d49437b4016e03e8f82fbbe5d1c62a9b61b9decae1"
dependencies = [
"chrono",
"inventory",
"prost 0.14.3",
"prost",
"serde",
"serde_derive",
"serde_json",
@@ -3774,27 +3683,27 @@ dependencies = [
[[package]]
name = "prost-wkt-build"
version = "0.7.1"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe500dc80e757a75e1e8fb7290e448d62dfba3105ece1d058579cb00b58151cd"
checksum = "07b8bf115b70a7aa5af1fd5d6e9418492e9ccb6e4785e858c938e28d132a884b"
dependencies = [
"heck 0.5.0",
"prost 0.14.3",
"prost",
"prost-build",
"prost-types 0.14.3",
"prost-types",
"quote",
]
[[package]]
name = "prost-wkt-types"
version = "0.7.1"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13807eaa7e15833d06e899008371926201cdcd11d74b6d490f49130cdb3f415e"
checksum = "c8cdde6df0a98311c839392ca2f2f0bcecd545f86a62b4e3c6a49c336e970fe5"
dependencies = [
"chrono",
"prost 0.14.3",
"prost",
"prost-build",
"prost-types 0.14.3",
"prost-types",
"prost-wkt",
"prost-wkt-build",
"regex",
@@ -3883,9 +3792,9 @@ dependencies = [
[[package]]
name = "quote"
version = "1.0.45"
version = "1.0.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
dependencies = [
"proc-macro2",
]
@@ -4022,9 +3931,9 @@ dependencies = [
[[package]]
name = "regex"
version = "1.12.3"
version = "1.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912"
dependencies = [
"aho-corasick",
"memchr",
@@ -4034,9 +3943,9 @@ dependencies = [
[[package]]
name = "regex-automata"
version = "0.4.14"
version = "0.4.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6"
dependencies = [
"aho-corasick",
"memchr",
@@ -5082,6 +4991,20 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "tonic-build"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9557ce109ea773b399c9b9e5dca39294110b74f1f342cb347a80d1fce8c26a11"
dependencies = [
"prettyplease",
"proc-macro2",
"prost-build",
"prost-types",
"quote",
"syn 2.0.106",
]
[[package]]
name = "tower"
version = "0.5.2"
@@ -11,6 +11,7 @@ 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"
napi-ohos = { version = "1.1", default-features = false, features = [
@@ -1,49 +1,13 @@
use crate::config::types::stored_config::{
SnapshotImportResult, StoredConfigList, StoredConfigMeta,
};
use once_cell::sync::Lazy;
use crate::config::types::stored_config::{StoredConfigList, StoredConfigMeta};
use ohos_hilog_binding::{hilog_debug, hilog_error};
use rusqlite::{Connection, OptionalExtension, params};
use std::collections::HashSet;
use std::ops::{Deref, DerefMut};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, MutexGuard};
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
static CONFIG_DB_PATH: Mutex<Option<PathBuf>> = Mutex::new(None);
static CONFIG_DB_CONNECTION: Lazy<Mutex<Option<CachedConfigDb>>> = Lazy::new(|| Mutex::new(None));
const CONFIG_DB_FILE_NAME: &str = "easytier-config-store.db";
struct CachedConfigDb {
path: PathBuf,
conn: Connection,
}
pub(crate) struct ConfigDbGuard<'a> {
guard: MutexGuard<'a, Option<CachedConfigDb>>,
}
impl Deref for ConfigDbGuard<'_> {
type Target = Connection;
fn deref(&self) -> &Self::Target {
&self
.guard
.as_ref()
.expect("config db connection guard must contain a connection")
.conn
}
}
impl DerefMut for ConfigDbGuard<'_> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self
.guard
.as_mut()
.expect("config db connection guard must contain a connection")
.conn
}
}
#[derive(Debug, Clone)]
struct StoredConfigMetaRecord {
config_id: String,
@@ -54,30 +18,6 @@ struct StoredConfigMetaRecord {
temporary: bool,
}
type SnapshotFieldRow = (String, String, String, String);
fn snapshot_import_ok() -> SnapshotImportResult {
SnapshotImportResult {
ok: true,
error_code: String::new(),
error_message: String::new(),
snapshot_invalid: false,
}
}
fn snapshot_import_err(
error_code: &str,
error_message: impl Into<String>,
snapshot_invalid: bool,
) -> SnapshotImportResult {
SnapshotImportResult {
ok: false,
error_code: error_code.to_string(),
error_message: error_message.into(),
snapshot_invalid,
}
}
pub(crate) fn now_ts_string() -> String {
SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -113,176 +53,31 @@ fn init_schema(conn: &Connection) -> rusqlite::Result<()> {
);
CREATE INDEX IF NOT EXISTS idx_stored_config_fields_config_id
ON stored_config_fields(config_id);",
)?;
ensure_column(
conn,
"stored_configs",
"favorite",
"ALTER TABLE stored_configs ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0;",
)?;
ensure_column(
conn,
"stored_configs",
"temporary",
"ALTER TABLE stored_configs ADD COLUMN temporary INTEGER NOT NULL DEFAULT 0;",
)?;
ensure_column(
conn,
"stored_config_fields",
"updated_at",
"ALTER TABLE stored_config_fields ADD COLUMN updated_at TEXT NOT NULL DEFAULT '0';",
)?;
if !validate_store_schema(conn)? {
return Err(rusqlite::Error::InvalidQuery);
}
conn.execute_batch("PRAGMA user_version = 1;")
)
}
fn table_columns(conn: &Connection, table_name: &str) -> rusqlite::Result<HashSet<String>> {
let mut stmt = conn.prepare(&format!("PRAGMA table_info({})", table_name))?;
let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
let mut columns = HashSet::new();
for row in rows {
columns.insert(row?);
}
Ok(columns)
}
fn ensure_column(
conn: &Connection,
table_name: &str,
column_name: &str,
alter_sql: &str,
) -> rusqlite::Result<()> {
let columns = table_columns(conn, table_name)?;
if !columns.contains(column_name) {
conn.execute_batch(alter_sql)?;
}
Ok(())
}
fn validate_store_schema(conn: &Connection) -> rusqlite::Result<bool> {
let meta_columns = table_columns(conn, "stored_configs")?;
let field_columns = table_columns(conn, "stored_config_fields")?;
let required_meta = [
"config_id",
"display_name",
"created_at",
"updated_at",
"favorite",
"temporary",
];
let required_fields = ["config_id", "field_name", "field_json", "updated_at"];
Ok(required_meta
.iter()
.all(|column| meta_columns.contains(*column))
&& required_fields
.iter()
.all(|column| field_columns.contains(*column)))
}
fn move_db_file_if_exists(path: &Path) -> bool {
if !path.exists() {
return true;
}
let target = PathBuf::from(format!(
"{}.corrupt.{}",
path.to_string_lossy(),
now_ts_string()
));
match std::fs::rename(path, &target) {
Ok(_) => true,
Err(e) => {
ohrs_log_error!(
"[Rust] failed to move corrupt config db {} to {}: {}",
path.display(),
target.display(),
e
);
false
}
}
}
fn recover_config_db_files(path: &Path) -> bool {
let main_ok = move_db_file_if_exists(path);
let wal_ok = move_db_file_if_exists(Path::new(&format!("{}-wal", path.to_string_lossy())));
let shm_ok = move_db_file_if_exists(Path::new(&format!("{}-shm", path.to_string_lossy())));
main_ok && wal_ok && shm_ok
}
fn open_connection(path: &Path) -> Option<Connection> {
let conn = match Connection::open(path) {
pub(crate) fn open_db() -> Option<Connection> {
let path = db_file_path()?;
let conn = match Connection::open(&path) {
Ok(conn) => conn,
Err(e) => {
ohrs_log_error!("[Rust] failed to open config db {}: {}", path.display(), e);
hilog_error!("[Rust] failed to open config db {}: {}", path.display(), e);
return None;
}
};
if let Err(e) = init_schema(&conn) {
ohrs_log_error!(
hilog_error!(
"[Rust] failed to initialize config db {}: {}",
path.display(),
e
);
drop(conn);
if !recover_config_db_files(path) {
return None;
}
let recovered = match Connection::open(path) {
Ok(conn) => conn,
Err(e) => {
ohrs_log_error!(
"[Rust] failed to open recovered config db {}: {}",
path.display(),
e
);
return None;
}
};
if let Err(e) = init_schema(&recovered) {
ohrs_log_error!(
"[Rust] failed to initialize recovered config db {}: {}",
path.display(),
e
);
return None;
}
return Some(recovered);
return None;
}
Some(conn)
}
pub(crate) fn open_db() -> Option<ConfigDbGuard<'static>> {
let path = db_file_path()?;
let mut guard = match CONFIG_DB_CONNECTION.lock() {
Ok(guard) => guard,
Err(e) => {
ohrs_log_error!("[Rust] failed to lock config db connection: {}", e);
return None;
}
};
let should_open = guard
.as_ref()
.map(|cached| cached.path != path || !cached.path.exists())
.unwrap_or(true);
if should_open {
let conn = open_connection(&path)?;
*guard = Some(CachedConfigDb { path, conn });
}
Some(ConfigDbGuard { guard })
}
fn row_to_meta(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoredConfigMetaRecord> {
Ok(StoredConfigMetaRecord {
config_id: row.get(0)?,
@@ -306,141 +101,6 @@ fn load_meta_record(conn: &Connection, config_id: &str) -> Option<StoredConfigMe
.flatten()
}
fn validate_snapshot_schema(conn: &Connection) -> bool {
let has_stored_configs = conn
.query_row(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'stored_configs'",
[],
|row| row.get::<_, i64>(0),
)
.optional()
.ok()
.flatten()
.is_some();
let has_stored_fields = conn
.query_row(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'stored_config_fields'",
[],
|row| row.get::<_, i64>(0),
)
.optional()
.ok()
.flatten()
.is_some();
has_stored_configs && has_stored_fields
}
fn read_snapshot_tables(
src: &Connection,
) -> rusqlite::Result<(Vec<StoredConfigMetaRecord>, Vec<SnapshotFieldRow>)> {
src.execute_batch("BEGIN DEFERRED TRANSACTION")?;
let mut meta_rows = Vec::<StoredConfigMetaRecord>::new();
let mut field_rows = Vec::<SnapshotFieldRow>::new();
let read_result = (|| -> rusqlite::Result<()> {
{
let mut stmt = src.prepare(
"SELECT config_id, display_name, created_at, updated_at, favorite, temporary
FROM stored_configs",
)?;
let rows = stmt.query_map([], row_to_meta)?;
for row in rows {
meta_rows.push(row?);
}
}
{
let mut stmt = src.prepare(
"SELECT config_id, field_name, field_json, updated_at
FROM stored_config_fields",
)?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
})?;
for row in rows {
field_rows.push(row?);
}
}
Ok(())
})();
match read_result {
Ok(()) => {
src.execute_batch("COMMIT")?;
Ok((meta_rows, field_rows))
}
Err(err) => {
let _ = src.execute_batch("ROLLBACK");
Err(err)
}
}
}
fn write_snapshot_tables(
dst: &mut Connection,
meta_rows: Vec<StoredConfigMetaRecord>,
field_rows: Vec<SnapshotFieldRow>,
) -> rusqlite::Result<()> {
let tx = dst.unchecked_transaction()?;
tx.execute("DELETE FROM stored_config_fields", [])?;
tx.execute("DELETE FROM stored_configs", [])?;
for row in meta_rows {
tx.execute(
"INSERT INTO stored_configs (
config_id, display_name, created_at, updated_at, favorite, temporary
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
row.config_id,
row.display_name,
row.created_at,
row.updated_at,
if row.favorite { 1 } else { 0 },
if row.temporary { 1 } else { 0 }
],
)?;
}
for (config_id, field_name, field_json, updated_at) in field_rows {
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, updated_at],
)?;
}
tx.commit()
}
fn copy_snapshot_tables(src: &Connection, dst: &mut Connection) -> rusqlite::Result<()> {
let (meta_rows, field_rows) = read_snapshot_tables(src)?;
write_snapshot_tables(dst, meta_rows, field_rows)
}
fn ensure_parent_dir(path: &Path) -> bool {
match path.parent() {
Some(parent) => match std::fs::create_dir_all(parent) {
Ok(_) => true,
Err(e) => {
ohrs_log_error!(
"[Rust] failed to create snapshot parent {}: {}",
parent.display(),
e
);
false
}
},
None => true,
}
}
fn to_meta(record: StoredConfigMetaRecord) -> StoredConfigMeta {
StoredConfigMeta {
config_id: record.config_id,
@@ -455,7 +115,7 @@ fn to_meta(record: StoredConfigMetaRecord) -> StoredConfigMeta {
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) {
ohrs_log_error!(
hilog_error!(
"[Rust] failed to create config db dir {}: {}",
root.display(),
e
@@ -469,7 +129,7 @@ pub fn init_config_meta_store(root_dir: String) -> bool {
*guard = Some(db_path.clone());
}
Err(e) => {
ohrs_log_error!("[Rust] failed to lock config db path: {}", e);
hilog_error!("[Rust] failed to lock config db path: {}", e);
return false;
}
}
@@ -478,141 +138,10 @@ pub fn init_config_meta_store(root_dir: String) -> bool {
return false;
}
ohrs_log_debug!("[Rust] initialized config db at {}", db_path.display());
hilog_debug!("[Rust] initialized config db at {}", db_path.display());
true
}
pub fn export_config_store_snapshot(target_path: String) -> bool {
let target = PathBuf::from(target_path);
if !ensure_parent_dir(&target) {
return false;
}
let Some(src) = open_db() else {
return false;
};
let mut dst = match Connection::open(&target) {
Ok(conn) => conn,
Err(e) => {
ohrs_log_error!(
"[Rust] failed to open snapshot target {}: {}",
target.display(),
e
);
return false;
}
};
if let Err(e) = init_schema(&dst) {
ohrs_log_error!(
"[Rust] failed to init snapshot schema {}: {}",
target.display(),
e
);
return false;
}
match copy_snapshot_tables(&src, &mut dst) {
Ok(_) => true,
Err(e) => {
ohrs_log_error!(
"[Rust] failed to export snapshot {}: {}",
target.display(),
e
);
false
}
}
}
pub fn import_config_store_snapshot_with_result(source_path: String) -> SnapshotImportResult {
let source = PathBuf::from(source_path);
let src = match Connection::open(&source) {
Ok(conn) => conn,
Err(e) => {
ohrs_log_error!(
"[Rust] failed to open snapshot source {}: {}",
source.display(),
e
);
return snapshot_import_err("source_open_failed", e.to_string(), false);
}
};
if !validate_snapshot_schema(&src) {
ohrs_log_error!("[Rust] invalid snapshot schema {}", source.display());
return snapshot_import_err(
"invalid_snapshot_schema",
format!("invalid snapshot schema: {}", source.display()),
true,
);
}
let (meta_rows, field_rows) = match read_snapshot_tables(&src) {
Ok(rows) => rows,
Err(e) => {
ohrs_log_error!(
"[Rust] failed to read snapshot source {}: {}",
source.display(),
e
);
return snapshot_import_err("invalid_snapshot_data", e.to_string(), true);
}
};
let Some(mut dst) = open_db() else {
return snapshot_import_err(
"destination_open_failed",
"failed to open local config store",
false,
);
};
match write_snapshot_tables(&mut dst, meta_rows, field_rows) {
Ok(_) => snapshot_import_ok(),
Err(e) => {
ohrs_log_error!(
"[Rust] failed to import snapshot {}: {}",
source.display(),
e
);
snapshot_import_err("destination_write_failed", e.to_string(), false)
}
}
}
pub fn import_config_store_snapshot(source_path: String) -> bool {
import_config_store_snapshot_with_result(source_path).ok
}
pub fn reset_config_meta_store() -> bool {
let Some(conn) = open_db() else {
return false;
};
let tx = match conn.unchecked_transaction() {
Ok(tx) => tx,
Err(e) => {
ohrs_log_error!(
"[Rust] failed to start config store reset transaction: {}",
e
);
return false;
}
};
if let Err(e) = tx.execute("DELETE FROM stored_config_fields", []) {
ohrs_log_error!("[Rust] failed to reset config fields: {}", e);
let _ = tx.rollback();
return false;
}
if let Err(e) = tx.execute("DELETE FROM stored_configs", []) {
ohrs_log_error!("[Rust] failed to reset config meta: {}", e);
let _ = tx.rollback();
return false;
}
match tx.commit() {
Ok(_) => true,
Err(e) => {
ohrs_log_error!("[Rust] failed to commit config store reset: {}", e);
false
}
}
}
pub fn list_config_meta_entries() -> StoredConfigList {
let Some(conn) = open_db() else {
return StoredConfigList { configs: vec![] };
@@ -625,7 +154,7 @@ pub fn list_config_meta_entries() -> StoredConfigList {
) {
Ok(stmt) => stmt,
Err(e) => {
ohrs_log_error!("[Rust] failed to prepare list meta query: {}", e);
hilog_error!("[Rust] failed to prepare list meta query: {}", e);
return StoredConfigList { configs: vec![] };
}
};
@@ -633,7 +162,7 @@ pub fn list_config_meta_entries() -> StoredConfigList {
let rows = match stmt.query_map([], row_to_meta) {
Ok(rows) => rows,
Err(e) => {
ohrs_log_error!("[Rust] failed to list config meta rows: {}", e);
hilog_error!("[Rust] failed to list config meta rows: {}", e);
return StoredConfigList { configs: vec![] };
}
};
@@ -652,6 +181,59 @@ pub fn get_config_meta(config_id: &str) -> Option<StoredConfigMeta> {
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,
@@ -733,45 +315,19 @@ pub fn set_config_display_name(
Some(to_meta(record))
}
pub fn set_config_favorite(config_id: String, favorite: bool) -> Option<StoredConfigMeta> {
let conn = open_db()?;
let now = now_ts_string();
let tx = conn.unchecked_transaction().ok()?;
pub fn delete_config_meta(config_id: &str) -> bool {
let Some(conn) = open_db() else {
return false;
};
if favorite {
tx.execute(
"UPDATE stored_configs
SET favorite = 0,
updated_at = CASE WHEN favorite != 0 THEN ?1 ELSE updated_at END
WHERE favorite != 0 AND config_id <> ?2",
params![now, config_id.clone()],
)
.ok()?;
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
}
}
let rows = tx
.execute(
"UPDATE stored_configs
SET favorite = ?2, updated_at = ?3
WHERE config_id = ?1",
params![config_id.clone(), if favorite { 1 } else { 0 }, now],
)
.ok()?;
if rows == 0 {
return None;
}
let meta = 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)?;
tx.commit().ok()?;
Some(meta)
}
@@ -35,6 +35,14 @@ 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)]
@@ -58,13 +66,3 @@ pub struct KeyValuePair {
pub key: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
#[napi(object)]
pub struct SnapshotImportResult {
pub ok: bool,
pub error_code: String,
pub error_message: String,
pub snapshot_invalid: bool,
}
+31 -139
View File
@@ -1,74 +1,21 @@
use super::{field_store, import_export, legacy_migration, validation};
use crate::config::storage::config_meta::{
get_config_meta, init_config_meta_store, list_config_meta_entries, open_db,
reset_config_meta_store, upsert_config_meta_in_tx,
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 once_cell::sync::Lazy;
use ohos_hilog_binding::{hilog_debug, hilog_error};
use rusqlite::params;
use serde_json::Value;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::Instant;
static CONFIG_ROOT_DIR: Mutex<Option<PathBuf>> = Mutex::new(None);
static RUNTIME_CONFIG_SNAPSHOTS: Lazy<Mutex<HashMap<String, RuntimeConfigSnapshot>>> =
Lazy::new(|| Mutex::new(HashMap::new()));
pub(crate) const CONFIG_DIR_NAME: &str = "easytier-configs";
pub(crate) const KERNEL_SOCKET_FILE_NAME: &str = "easytier-kernel.sock";
#[derive(Clone)]
pub(crate) struct RuntimeConfigSnapshot {
pub display_name: String,
pub config: NetworkConfig,
}
pub(crate) fn cache_runtime_config_snapshot(
config_id: String,
display_name: String,
config: NetworkConfig,
) {
if let Ok(mut guard) = RUNTIME_CONFIG_SNAPSHOTS.lock() {
guard.insert(
config_id,
RuntimeConfigSnapshot {
display_name,
config,
},
);
}
}
pub(crate) fn clear_runtime_config_snapshot(config_id: &str) {
if let Ok(mut guard) = RUNTIME_CONFIG_SNAPSHOTS.lock() {
guard.remove(config_id);
}
}
pub(crate) fn get_runtime_config_snapshot(config_id: &str) -> Option<RuntimeConfigSnapshot> {
RUNTIME_CONFIG_SNAPSHOTS
.lock()
.ok()
.and_then(|guard| guard.get(config_id).cloned())
}
pub(crate) fn get_runtime_config_route_overrides(config_id: &str) -> (Vec<String>, Vec<String>) {
RUNTIME_CONFIG_SNAPSHOTS
.lock()
.ok()
.and_then(|guard| {
guard.get(config_id).map(|snapshot| {
(
snapshot.config.routes.clone(),
snapshot.config.proxy_cidrs.clone(),
)
})
})
.unwrap_or_default()
}
pub(crate) fn config_root_dir() -> Option<PathBuf> {
CONFIG_ROOT_DIR
.lock()
@@ -88,7 +35,7 @@ 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) {
ohrs_log_error!(
hilog_error!(
"[Rust] failed to create config dir {}: {}",
configs_dir.display(),
e
@@ -101,7 +48,7 @@ pub fn init_config_store(root_dir: String) -> bool {
*guard = Some(root.clone());
}
Err(e) => {
ohrs_log_error!("[Rust] failed to lock config root dir: {}", e);
hilog_error!("[Rust] failed to lock config root dir: {}", e);
return false;
}
}
@@ -110,27 +57,14 @@ pub fn init_config_store(root_dir: String) -> bool {
return false;
}
ohrs_log_debug!(
hilog_debug!(
"[Rust] initialized config repo at {}",
configs_dir.display()
);
true
}
pub fn reset_config_store() -> bool {
if !reset_config_meta_store() {
return false;
}
if let Ok(mut guard) = RUNTIME_CONFIG_SNAPSHOTS.lock() {
guard.clear();
}
true
}
fn migrate_legacy_file_if_needed(config_id: &str) -> Option<()> {
if validation::validate_config_id(config_id).is_err() {
return None;
}
legacy_migration::migrate_legacy_file_if_needed(
&config_root_dir(),
CONFIG_DIR_NAME,
@@ -147,7 +81,7 @@ pub fn save_config_record(
let config = match validation::validate_config_json(&config_json, config_id.clone()) {
Ok(config) => config,
Err(e) => {
ohrs_log_error!("[Rust] save_config_record failed {}", e);
hilog_error!("[Rust] save_config_record failed {}", e);
return None;
}
};
@@ -155,7 +89,7 @@ pub fn save_config_record(
let normalized_json = match serde_json::to_string(&config) {
Ok(raw) => raw,
Err(e) => {
ohrs_log_error!(
hilog_error!(
"[Rust] failed to serialize normalized config {}: {}",
config_id,
e
@@ -171,15 +105,15 @@ pub fn save_config_record(
let conn = open_db()?;
let tx = conn.unchecked_transaction().ok()?;
let existing_meta = tx
.query_row(
"SELECT favorite, temporary FROM stored_configs WHERE config_id = ?1",
params![config_id.clone()],
|row| Ok((row.get::<_, i64>(0)? != 0, row.get::<_, i64>(1)? != 0)),
)
.ok();
let favorite = existing_meta.map(|meta| meta.0).unwrap_or(false);
let temporary = existing_meta.map(|meta| meta.1).unwrap_or(false);
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)?;
@@ -199,52 +133,30 @@ pub fn save_config_record(
}
pub fn load_config_json(config_id: &str) -> Option<String> {
validation::validate_config_id(config_id).ok()?;
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> {
validation::validate_config_id(config_id).ok()?;
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> {
let total_start = Instant::now();
validation::validate_config_id(config_id).ok()?;
migrate_legacy_file_if_needed(config_id)?;
let open_start = Instant::now();
let conn = open_db()?;
let open_elapsed = open_start.elapsed();
let query_start = Instant::now();
let result = conn
.query_row(
"SELECT field_json FROM stored_config_fields
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();
ohrs_log_debug!(
"[Rust] get_config_field_value config={} field={} found={} open_ms={} query_ms={} total_ms={} len={}",
config_id,
field,
result.is_some(),
open_elapsed.as_millis(),
query_start.elapsed().as_millis(),
total_start.elapsed().as_millis(),
result.as_ref().map(|value| value.len()).unwrap_or(0)
);
result
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 validation::validate_config_id(config_id).is_err() {
return false;
}
if field.contains('.') {
return false;
}
@@ -279,12 +191,15 @@ pub fn set_config_field_value(config_id: &str, field: &str, json_value: &str) ->
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> {
validation::validate_config_id(&config_id).ok()?;
let raw = get_default_config_json()?;
let mut config = serde_json::from_str::<NetworkConfig>(&raw).ok()?;
config.instance_id = Some(config_id.clone());
@@ -293,21 +208,11 @@ pub fn create_config_record(config_id: String, display_name: String) -> Option<S
}
pub fn start_kernel_with_config_id(config_id: &str) -> bool {
if validation::validate_config_id(config_id).is_err() {
return false;
}
let raw = match load_config_json(config_id) {
Some(raw) => raw,
None => return false,
};
let display_name = get_config_meta(config_id)
.map(|meta| meta.display_name)
.unwrap_or_else(|| config_id.to_string());
let started = crate::run_network_instance_from_json(&raw);
if started && let Ok(config) = serde_json::from_str::<NetworkConfig>(&raw) {
cache_runtime_config_snapshot(config_id.to_string(), display_name, config);
}
started
crate::run_network_instance_from_json(&raw)
}
pub fn list_config_meta_json() -> String {
@@ -315,9 +220,6 @@ pub fn list_config_meta_json() -> String {
}
pub fn delete_config_record(config_id: &str) -> bool {
if validation::validate_config_id(config_id).is_err() {
return false;
}
if let Some(path) = legacy_config_file_path(config_id) {
if path.exists() {
let _ = std::fs::remove_file(path);
@@ -332,24 +234,14 @@ pub fn delete_config_record(config_id: &str) -> bool {
"DELETE FROM stored_config_fields WHERE config_id = ?1",
params![config_id],
) {
ohrs_log_error!("[Rust] failed to delete config fields {}: {}", config_id, e);
hilog_error!("[Rust] failed to delete config fields {}: {}", config_id, e);
return false;
}
match conn.execute(
"DELETE FROM stored_configs WHERE config_id = ?1",
params![config_id],
) {
Ok(rows) => rows > 0,
Err(e) => {
ohrs_log_error!("[Rust] failed to delete config meta {}: {}", config_id, e);
false
}
}
delete_config_meta(config_id)
}
pub fn export_config_toml(config_id: &str) -> Option<ExportTomlResult> {
validation::validate_config_id(config_id).ok()?;
let record = get_config_record(config_id)?;
import_export::export_config_toml_from_record(&record)
}
@@ -1,4 +1,5 @@
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};
@@ -42,7 +43,7 @@ pub(super) fn replace_config_fields(
"DELETE FROM stored_config_fields WHERE config_id = ?1",
params![config_id],
) {
ohrs_log_error!(
hilog_error!(
"[Rust] failed to clear existing config fields {}: {}",
config_id,
e
@@ -57,7 +58,7 @@ pub(super) fn replace_config_fields(
VALUES (?1, ?2, ?3, ?4)",
params![config_id, field_name, field_json, now_ts_string()],
) {
ohrs_log_error!("[Rust] failed to persist config field {}: {}", config_id, e);
hilog_error!("[Rust] failed to persist config field {}: {}", config_id, e);
return None;
}
}
@@ -1,17 +1,12 @@
use crate::config::storage::config_meta::get_config_meta;
use ohos_hilog_binding::hilog_error;
use std::path::PathBuf;
use super::validation;
pub(super) fn legacy_config_file_path(
root_dir: &Option<PathBuf>,
config_dir_name: &str,
config_id: &str,
) -> Option<PathBuf> {
if !validation::is_valid_config_id(config_id) {
ohrs_log_error!("[Rust] invalid legacy config_id {}", config_id);
return None;
}
root_dir.as_ref().map(|root| {
root.join(config_dir_name)
.join(format!("{}.json", config_id))
@@ -40,7 +35,7 @@ pub(super) fn migrate_legacy_file_if_needed(
save_config_record(config_id.to_string(), display_name, raw)?;
if let Err(e) = std::fs::remove_file(&legacy_path) {
ohrs_log_error!(
hilog_error!(
"[Rust] failed to remove legacy config file {}: {}",
legacy_path.display(),
e
@@ -1,25 +1,13 @@
use easytier::proto::api::manage::NetworkConfig;
use serde_json::{Map, Value};
use uuid::Uuid;
pub(super) fn validate_config_id(config_id: &str) -> Result<(), String> {
if config_id.is_empty() {
return Err("config_id is required".to_string());
}
Uuid::parse_str(config_id)
.map(|_| ())
.map_err(|e| format!("invalid config_id {}: {}", config_id, e))
}
pub(super) fn is_valid_config_id(config_id: &str) -> bool {
validate_config_id(config_id).is_ok()
}
pub(super) fn normalize_config_id(
mut config: NetworkConfig,
requested_id: String,
) -> Result<NetworkConfig, String> {
validate_config_id(&requested_id)?;
if requested_id.is_empty() {
return Err("config_id is required".to_string());
}
config.instance_id = Some(requested_id);
Ok(config)
}
@@ -1,14 +1,9 @@
use crate::config;
use crate::config::types::stored_config::SnapshotImportResult;
pub(crate) fn init_config_store(root_dir: String) -> bool {
config::repository::init_config_store(root_dir)
}
pub(crate) fn reset_config_store() -> bool {
config::repository::reset_config_store()
}
pub(crate) fn list_configs() -> String {
config::repository::list_config_meta_json()
}
@@ -41,10 +36,6 @@ pub(crate) fn set_config_field(config_id: String, field: String, json_value: Str
config::repository::set_config_field_value(&config_id, &field, &json_value)
}
pub(crate) fn set_config_favorite(config_id: String, favorite: bool) -> bool {
config::storage::config_meta::set_config_favorite(config_id, favorite).is_some()
}
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)
@@ -53,17 +44,3 @@ pub(crate) fn import_toml(toml_text: String, display_name: Option<String>) -> Op
pub(crate) fn export_toml(config_id: String) -> Option<String> {
config::repository::export_config_toml(&config_id).map(|ret| ret.toml_text)
}
pub(crate) fn export_config_store_snapshot(target_path: String) -> bool {
config::storage::config_meta::export_config_store_snapshot(target_path)
}
pub(crate) fn import_config_store_snapshot(source_path: String) -> bool {
config::storage::config_meta::import_config_store_snapshot(source_path)
}
pub(crate) fn import_config_store_snapshot_with_result(
source_path: String,
) -> SnapshotImportResult {
config::storage::config_meta::import_config_store_snapshot_with_result(source_path)
}
@@ -1,15 +1,18 @@
use crate::config::repository::{clear_runtime_config_snapshot, get_runtime_config_snapshot};
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, RuntimeInstanceState, TunAggregateState, clear_tun_attached,
is_tun_attached, mark_tun_attached, runtime_instance_from_config_snapshot,
RuntimeAggregateState, TunAggregateState, clear_tun_attached, mark_tun_attached,
runtime_instance_from_running_info,
};
use crate::{ASYNC_RUNTIME, INSTANCE_MANAGER, WEB_CLIENTS};
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,
@@ -26,12 +29,9 @@ pub(crate) fn stop_kernel(
) -> bool {
clear_tun_attached(&config_id);
if stop_web_client(&config_id) {
clear_runtime_config_snapshot(&config_id);
return true;
}
let _ = stop_local_socket_server_inner();
let Some(instance_id) = parse_instance_uuid(&config_id) else {
return false;
};
@@ -40,20 +40,9 @@ pub(crate) fn stop_kernel(
.delete_network_instance(vec![instance_id])
.map(|_| true)
.unwrap_or_else(|err| {
ohrs_log_error!("[Rust] stop_kernel failed {}: {}", config_id, err);
hilog_error!("[Rust] stop_kernel failed {}: {}", config_id, err);
false
});
if ret {
clear_runtime_config_snapshot(&config_id);
}
let has_active_instances = !INSTANCE_MANAGER.list_network_instance_ids().is_empty();
let has_web_clients = WEB_CLIENTS
.lock()
.map(|guard| !guard.is_empty())
.unwrap_or(false);
if has_active_instances || has_web_clients {
let _ = start_local_socket_server_inner();
}
maybe_stop_local_socket_server();
ret
}
@@ -70,10 +59,10 @@ pub(crate) fn stop_network_instance(
}
pub(crate) fn collect_network_infos() -> Vec<KeyValuePair> {
let infos = match ASYNC_RUNTIME.block_on(INSTANCE_MANAGER.collect_network_infos()) {
let infos = match INSTANCE_MANAGER.collect_network_infos_sync() {
Ok(infos) => infos,
Err(err) => {
ohrs_log_error!("[Rust] collect network infos failed {}", err);
hilog_error!("[Rust] collect network infos failed {}", err);
return vec![];
}
};
@@ -97,7 +86,7 @@ pub(crate) fn set_tun_fd(
parse_instance_uuid: impl Fn(&str) -> Option<uuid::Uuid>,
) -> bool {
let Some(instance_id) = parse_instance_uuid(&config_id) else {
ohrs_log_error!("[Rust] set_tun_fd invalid instance id: {}", config_id);
hilog_error!("[Rust] set_tun_fd invalid instance id: {}", config_id);
return false;
};
@@ -105,7 +94,7 @@ pub(crate) fn set_tun_fd(
.set_tun_fd(&instance_id, fd)
.map(|_| {
mark_tun_attached(&config_id);
ohrs_log_info!(
hilog_info!(
"[Rust] set_tun_fd success instance={} fd={} marked_attached=true",
config_id,
fd
@@ -113,16 +102,20 @@ pub(crate) fn set_tun_fd(
true
})
.unwrap_or_else(|err| {
ohrs_log_error!("[Rust] set_tun_fd failed {}: {}", config_id, err);
hilog_error!("[Rust] set_tun_fd failed {}: {}", config_id, err);
false
})
}
pub(crate) fn collect_runtime_state() -> RuntimeAggregateState {
let infos = match ASYNC_RUNTIME.block_on(INSTANCE_MANAGER.collect_network_infos()) {
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) => {
ohrs_log_error!("[Rust] collect network infos failed {}", err);
hilog_error!("[Rust] collect network infos failed {}", err);
return RuntimeAggregateState {
instances: vec![],
tun: TunAggregateState {
@@ -136,67 +129,30 @@ pub(crate) fn collect_runtime_state() -> RuntimeAggregateState {
};
}
};
let mut live_infos = infos
.into_iter()
.map(|(instance_id, info)| (instance_id.to_string(), info))
.collect::<std::collections::HashMap<_, _>>();
let mut active_config_ids = live_infos.keys().cloned().collect::<Vec<_>>();
if let Ok(guard) = WEB_CLIENTS.lock() {
for config_id in guard.keys() {
if !active_config_ids.iter().any(|value| value == config_id) {
active_config_ids.push(config_id.clone());
}
}
}
let mut instances = Vec::with_capacity(active_config_ids.len());
for config_id in active_config_ids {
if let Some(info) = live_infos.remove(&config_id) {
let snapshot = get_runtime_config_snapshot(&config_id);
let display_name = snapshot
.as_ref()
.map(|snapshot| snapshot.display_name.clone())
.unwrap_or_else(|| config_id.clone());
let magic_dns_enabled = snapshot
.as_ref()
.and_then(|snapshot| snapshot.config.enable_magic_dns)
.unwrap_or(false);
let need_exit_node = snapshot
.as_ref()
.map(|snapshot| !snapshot.config.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,
));
} else if let Some(snapshot) = get_runtime_config_snapshot(&config_id) {
instances.push(runtime_instance_from_config_snapshot(
config_id,
snapshot.display_name,
snapshot.config,
true,
));
} else {
let tun_attached = is_tun_attached(&config_id);
instances.push(RuntimeInstanceState {
config_id: config_id.clone(),
instance_id: config_id.clone(),
display_name: config_id.clone(),
running: true,
tun_required: tun_attached,
tun_attached,
magic_dns_enabled: false,
need_exit_node: false,
error_message: None,
my_node_info: None,
events: Vec::new(),
routes: Vec::new(),
peers: Vec::new(),
});
}
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| {
@@ -32,13 +32,6 @@ pub(crate) fn send_local_socket_message(
Ok(())
}
fn shrink_clients_if_sparse(clients: &mut Vec<UnixStream>) {
let sparse_limit = clients.len().saturating_mul(2).max(4);
if clients.capacity() > sparse_limit {
clients.shrink_to_fit();
}
}
pub(crate) fn broadcast_local_socket_message(
clients: &mut Vec<UnixStream>,
message_type: &str,
@@ -52,42 +45,6 @@ pub(crate) fn broadcast_local_socket_message(
active_clients.push(client);
}
}
shrink_clients_if_sparse(&mut active_clients);
*clients = active_clients;
delivered
}
pub(crate) fn send_local_socket_json_payload_message(
stream: &mut UnixStream,
message_type: &str,
payload_json: &str,
) -> std::io::Result<()> {
let message_type_json = serde_json::to_string(message_type)
.map_err(|err| Error::new(ErrorKind::InvalidData, err.to_string()))?;
let mut raw = Vec::with_capacity(message_type_json.len() + payload_json.len() + 38);
raw.extend_from_slice(b"{\"messageType\":");
raw.extend_from_slice(message_type_json.as_bytes());
raw.extend_from_slice(b",\"payloadJson\":");
raw.extend_from_slice(payload_json.as_bytes());
raw.extend_from_slice(b"}\n");
stream.write_all(&raw)?;
Ok(())
}
pub(crate) fn broadcast_local_socket_json_payload_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_json_payload_message(&mut client, message_type, payload_json).is_ok() {
delivered = true;
active_clients.push(client);
}
}
shrink_clients_if_sparse(&mut active_clients);
*clients = active_clients;
delivered
}
@@ -1,12 +1,20 @@
use crate::config::repository::get_runtime_config_route_overrides;
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> {
let normalized = route.split("->").next().unwrap_or(route).trim();
normalized
route
.parse::<IpNet>()
.ok()
.map(|network| match network {
@@ -14,7 +22,7 @@ fn normalize_route_cidr(route: &str) -> Option<String> {
IpNet::V6(net) => net.trunc().to_string(),
})
.or_else(|| {
normalized.parse::<IpAddr>().ok().map(|addr| match addr {
route.parse::<IpAddr>().ok().map(|addr| match addr {
IpAddr::V4(ip) => format!("{}/32", ip),
IpAddr::V6(ip) => format!("{}/128", ip),
})
@@ -59,9 +67,8 @@ pub(crate) fn aggregate_tun_routes(instance: &RuntimeInstanceState) -> Vec<Strin
.my_node_info
.as_ref()
.and_then(|info| info.virtual_ipv4_cidr.clone());
let (manual_routes, config_proxy_cidrs) =
get_runtime_config_route_overrides(&instance.config_id);
let runtime_proxy_cidrs = instance
let manual_routes = load_manual_routes(&instance.config_id);
let proxy_cidrs = instance
.routes
.iter()
.flat_map(|route| route.proxy_cidrs.iter().cloned())
@@ -73,9 +80,15 @@ pub(crate) fn aggregate_tun_routes(instance: &RuntimeInstanceState) -> Vec<Strin
}
raw_routes.extend(manual_routes.iter().cloned());
raw_routes.extend(config_proxy_cidrs.iter().cloned());
raw_routes.extend(runtime_proxy_cidrs.iter().cloned());
simplify_routes(raw_routes)
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> {
@@ -1,27 +1,17 @@
use super::protocol::{
TunRequestPayload, broadcast_local_socket_json_payload_message, broadcast_local_socket_message,
};
use crate::collect_runtime_state_inner;
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 crate::runtime::state::runtime_state::{
PeerConnInfo as RuntimePeerConnInfo, RuntimeAggregateState, peer_conn_to_view,
};
use crate::{ASYNC_RUNTIME, INSTANCE_MANAGER};
use easytier::common::global_ctx::{EventBusSubscriber, GlobalCtxEvent};
use easytier::proto::api::instance::ListPeerRequest;
use easytier::proto::rpc_types::controller::BaseController;
use ohos_hilog_binding::{hilog_error, hilog_info};
use once_cell::sync::Lazy;
use serde::Serialize;
use std::collections::{HashMap, HashSet};
use std::hash::Hash;
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, Instant};
use std::time::Duration;
struct LocalSocketState {
stop_flag: std::sync::Arc<AtomicBool>,
@@ -30,287 +20,12 @@ struct LocalSocketState {
}
static LOCAL_SOCKET_STATE: Lazy<Mutex<Option<LocalSocketState>>> = Lazy::new(|| Mutex::new(None));
const SOCKET_TICK_INTERVAL: Duration = Duration::from_millis(250);
const TRAFFIC_STATS_INTERVAL: Duration = Duration::from_secs(1);
const INSTANCE_POLL_INTERVAL: Duration = Duration::from_secs(1);
const TUN_FAST_CHECK_WINDOW: Duration = Duration::from_secs(8);
const EVENT_RECEIVER_SYNC_INTERVAL: Duration = Duration::from_secs(1);
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct TrafficStatsPayload {
instances: Vec<InstanceTrafficStats>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct InstanceTrafficStats {
config_id: String,
instance_id: String,
rx_bytes: i64,
tx_bytes: i64,
peers: Vec<PeerTrafficStats>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct PeerTrafficStats {
peer_id: i64,
rx_bytes: i64,
tx_bytes: i64,
total_bytes: i64,
latency_us: i64,
loss_rate: f64,
}
struct PendingPeerEvent {
event: &'static str,
instance_id: String,
peer_id: i64,
conn: Option<RuntimePeerConnInfo>,
}
#[derive(Default)]
struct DrainedKernelEvents {
tun_refresh: bool,
topology_lost: bool,
peer_events: Vec<PendingPeerEvent>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct RuntimePeerEventPayload {
event: &'static str,
config_id: String,
instance_id: String,
peer_id: i64,
conn: Option<RuntimePeerConnInfo>,
}
fn shrink_hash_map_if_sparse<K: Eq + Hash, V>(map: &mut HashMap<K, V>) {
let sparse_limit = map.len().saturating_mul(2).max(8);
if map.capacity() > sparse_limit {
map.shrink_to_fit();
}
}
fn shrink_hash_set_if_sparse<T: Eq + Hash>(set: &mut HashSet<T>) {
let sparse_limit = set.len().saturating_mul(2).max(8);
if set.capacity() > sparse_limit {
set.shrink_to_fit();
}
}
fn sync_tun_event_receivers(receivers: &mut HashMap<String, EventBusSubscriber>) {
let mut active_instance_ids = HashSet::new();
for instance in INSTANCE_MANAGER.iter() {
let instance_id = instance.key().to_string();
active_instance_ids.insert(instance_id.clone());
if !receivers.contains_key(&instance_id)
&& let Some(receiver) = instance.value().subscribe_event()
{
receivers.insert(instance_id, receiver);
}
}
receivers.retain(|instance_id, _| active_instance_ids.contains(instance_id));
shrink_hash_map_if_sparse(receivers);
}
fn event_needs_tun_refresh(event: &GlobalCtxEvent) -> bool {
matches!(
event,
GlobalCtxEvent::DhcpIpv4Changed(_, _)
| GlobalCtxEvent::ProxyCidrsUpdated(_, _)
| GlobalCtxEvent::PublicIpv6RoutesUpdated(_, _)
)
}
fn drain_kernel_events(receivers: &mut HashMap<String, EventBusSubscriber>) -> DrainedKernelEvents {
let mut drained = DrainedKernelEvents::default();
let mut closed_receivers = Vec::new();
for (instance_id, receiver) in receivers.iter_mut() {
loop {
match receiver.try_recv() {
Ok(event) => {
drained.tun_refresh = event_needs_tun_refresh(&event) || drained.tun_refresh;
match event {
GlobalCtxEvent::PeerAdded(peer_id) => {
drained.peer_events.push(PendingPeerEvent {
event: "peer_added",
instance_id: instance_id.clone(),
peer_id: peer_id as i64,
conn: None,
});
}
GlobalCtxEvent::PeerRemoved(peer_id) => {
drained.peer_events.push(PendingPeerEvent {
event: "peer_removed",
instance_id: instance_id.clone(),
peer_id: peer_id as i64,
conn: None,
});
}
GlobalCtxEvent::PeerConnAdded(conn_info) => {
let peer_id = conn_info.peer_id as i64;
drained.peer_events.push(PendingPeerEvent {
event: "peer_conn_added",
instance_id: instance_id.clone(),
peer_id,
conn: Some(peer_conn_to_view(conn_info)),
});
}
GlobalCtxEvent::PeerConnRemoved(conn_info) => {
let peer_id = conn_info.peer_id as i64;
drained.peer_events.push(PendingPeerEvent {
event: "peer_conn_removed",
instance_id: instance_id.clone(),
peer_id,
conn: Some(peer_conn_to_view(conn_info)),
});
}
_ => {}
}
}
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => break,
Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => {
drained.topology_lost = true;
continue;
}
Err(tokio::sync::broadcast::error::TryRecvError::Closed) => {
closed_receivers.push(instance_id.clone());
break;
}
}
}
}
for instance_id in closed_receivers {
receivers.remove(&instance_id);
}
drained
}
fn broadcast_runtime_peer_events(
clients: &mut Vec<UnixStream>,
peer_events: Vec<PendingPeerEvent>,
) {
for event in peer_events {
let payload = RuntimePeerEventPayload {
event: event.event,
config_id: event.instance_id.clone(),
instance_id: event.instance_id,
peer_id: event.peer_id,
conn: event.conn,
};
match serde_json::to_string(&payload) {
Ok(json) => {
let _ = broadcast_local_socket_json_payload_message(
clients,
"runtime_peer_event",
&json,
);
}
Err(err) => {
ohrs_log_error!("[Rust] serialize runtime peer event failed: {}", err);
}
}
}
}
fn tun_candidate_ids(snapshot: &RuntimeAggregateState) -> HashSet<String> {
snapshot
.instances
.iter()
.filter(|instance| instance.running && instance.tun_required)
.map(|instance| instance.instance_id.clone())
.collect()
}
fn collect_traffic_stats() -> TrafficStatsPayload {
let services = INSTANCE_MANAGER
.iter()
.filter_map(|instance| {
instance
.value()
.get_api_service()
.map(|api_service| (instance.key().to_string(), api_service))
})
.collect::<Vec<_>>();
let instances = ASYNC_RUNTIME.block_on(async {
let mut instances = Vec::new();
for (instance_id, api_service) in services {
let peers = match api_service
.get_peer_manage_service()
.list_peer(BaseController::default(), ListPeerRequest::default())
.await
{
Ok(response) => response.peer_infos,
Err(err) => {
ohrs_log_debug!(
"[Rust] collect traffic stats list_peer failed instance={}: {}",
instance_id,
err
);
continue;
}
};
let mut instance_rx_bytes = 0i64;
let mut instance_tx_bytes = 0i64;
let mut peer_stats = Vec::with_capacity(peers.len());
for peer in peers {
let mut peer_rx_bytes = 0i64;
let mut peer_tx_bytes = 0i64;
let mut latency_us = i64::MAX;
let mut loss_rate = 0f64;
for conn in peer.conns {
if let Some(stats) = conn.stats {
let rx_bytes = stats.rx_bytes as i64;
let tx_bytes = stats.tx_bytes as i64;
peer_rx_bytes += rx_bytes;
peer_tx_bytes += tx_bytes;
latency_us = latency_us.min(stats.latency_us as i64);
}
loss_rate = loss_rate.max(conn.loss_rate as f64);
}
instance_rx_bytes += peer_rx_bytes;
instance_tx_bytes += peer_tx_bytes;
peer_stats.push(PeerTrafficStats {
peer_id: peer.peer_id as i64,
rx_bytes: peer_rx_bytes,
tx_bytes: peer_tx_bytes,
total_bytes: peer_rx_bytes + peer_tx_bytes,
latency_us: if latency_us == i64::MAX {
-1
} else {
latency_us
},
loss_rate,
});
}
instances.push(InstanceTrafficStats {
config_id: instance_id.clone(),
instance_id,
rx_bytes: instance_rx_bytes,
tx_bytes: instance_tx_bytes,
peers: peer_stats,
});
}
instances
});
TrafficStatsPayload { instances }
}
pub fn start_local_socket_server() -> bool {
let socket_path = match kernel_socket_path() {
Some(path) => path,
None => {
ohrs_log_error!("[Rust] kernel socket path unavailable");
hilog_error!("[Rust] kernel socket path unavailable");
return false;
}
};
@@ -319,7 +34,7 @@ pub fn start_local_socket_server() -> bool {
Ok(guard) if guard.is_some() => return true,
Ok(_) => {}
Err(err) => {
ohrs_log_error!("[Rust] lock localsocket state failed: {}", err);
hilog_error!("[Rust] lock localsocket state failed: {}", err);
return false;
}
}
@@ -331,7 +46,7 @@ pub fn start_local_socket_server() -> bool {
let listener = match UnixListener::bind(&socket_path) {
Ok(listener) => listener,
Err(err) => {
ohrs_log_error!(
hilog_error!(
"[Rust] bind localsocket failed {}: {}",
socket_path.display(),
err
@@ -340,7 +55,7 @@ pub fn start_local_socket_server() -> bool {
}
};
if let Err(err) = listener.set_nonblocking(true) {
ohrs_log_error!("[Rust] set localsocket nonblocking failed: {}", err);
hilog_error!("[Rust] set localsocket nonblocking failed: {}", err);
let _ = std::fs::remove_file(&socket_path);
return false;
}
@@ -348,208 +63,102 @@ pub fn start_local_socket_server() -> bool {
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_topology_json = String::new();
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 tun_fast_until = Instant::now() + TUN_FAST_CHECK_WINDOW;
let mut tun_bootstrap_done = false;
let mut last_event_receiver_sync_at: Option<Instant> = None;
let mut last_traffic_stats_at: Option<Instant> = None;
let mut last_instance_poll_at: Option<Instant> = None;
let mut tun_event_receivers = HashMap::<String, EventBusSubscriber>::new();
let mut clients = Vec::<UnixStream>::new();
while !worker_stop_flag.load(Ordering::Relaxed) {
let mut full_topology_dirty = false;
let mut accepted_client = false;
loop {
match listener.accept() {
Ok((stream, _addr)) => {
accepted_client = true;
full_topology_dirty = true;
clients.push(stream);
tun_fast_until = Instant::now() + TUN_FAST_CHECK_WINDOW;
tun_bootstrap_done = false;
}
Err(err) if err.kind() == ErrorKind::WouldBlock => break,
Err(err) => {
ohrs_log_error!("[Rust] accept localsocket failed: {}", err);
hilog_error!("[Rust] accept localsocket failed: {}", err);
break;
}
}
}
if clients.is_empty() {
if !last_topology_json.is_empty() {
last_topology_json.clear();
last_topology_json.shrink_to_fit();
}
delivered_tun_requests.clear();
shrink_hash_set_if_sparse(&mut delivered_tun_requests);
last_tun_route_signatures.clear();
shrink_hash_map_if_sparse(&mut last_tun_route_signatures);
tun_event_receivers.clear();
shrink_hash_map_if_sparse(&mut tun_event_receivers);
clients.shrink_to_fit();
last_event_receiver_sync_at = None;
last_traffic_stats_at = None;
last_instance_poll_at = None;
tun_bootstrap_done = false;
thread::sleep(SOCKET_TICK_INTERVAL);
continue;
}
let now = Instant::now();
let should_sync_event_receivers = accepted_client
|| last_event_receiver_sync_at
.map(|last| now.duration_since(last) >= EVENT_RECEIVER_SYNC_INTERVAL)
.unwrap_or(true);
if should_sync_event_receivers {
sync_tun_event_receivers(&mut tun_event_receivers);
last_event_receiver_sync_at = Some(now);
}
let drained_events = drain_kernel_events(&mut tun_event_receivers);
let tun_refresh = drained_events.tun_refresh;
let topology_lost = drained_events.topology_lost;
let peer_events = drained_events.peer_events;
if topology_lost {
full_topology_dirty = true;
}
if tun_refresh {
tun_bootstrap_done = false;
tun_fast_until = now + TUN_FAST_CHECK_WINDOW;
}
if !peer_events.is_empty() {
broadcast_runtime_peer_events(&mut clients, peer_events);
}
let should_collect_traffic_stats = last_traffic_stats_at
.map(|last| now.duration_since(last) >= TRAFFIC_STATS_INTERVAL)
.unwrap_or(true);
if should_collect_traffic_stats {
last_traffic_stats_at = Some(now);
match serde_json::to_string(&collect_traffic_stats()) {
Ok(json) => {
let _ = broadcast_local_socket_json_payload_message(
&mut clients,
"traffic_stats",
&json,
);
}
Err(err) => {
ohrs_log_error!("[Rust] serialize traffic stats failed: {}", err);
}
}
}
let should_poll_instance = last_instance_poll_at
.map(|last| now.duration_since(last) >= INSTANCE_POLL_INTERVAL)
.unwrap_or(true);
let should_collect_topology = accepted_client
|| full_topology_dirty
|| tun_refresh
|| should_poll_instance
|| (!tun_bootstrap_done && now < tun_fast_until);
if !should_collect_topology {
thread::sleep(SOCKET_TICK_INTERVAL);
continue;
}
let snapshot = collect_runtime_state_inner();
last_instance_poll_at = Some(now);
match serde_json::to_string(&snapshot) {
Ok(json) => {
if accepted_client || full_topology_dirty || json != last_topology_json {
let _ = broadcast_local_socket_json_payload_message(
&mut clients,
"runtime_topology",
&json,
);
last_topology_json = json;
}
}
let snapshot = get_runtime_snapshot_inner();
let snapshot_json = match serde_json::to_string(&snapshot) {
Ok(json) => json,
Err(err) => {
ohrs_log_error!("[Rust] serialize runtime topology failed: {}", 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;
}
let active_tun_candidate_ids = tun_candidate_ids(&snapshot);
delivered_tun_requests
.retain(|instance_id| active_tun_candidate_ids.contains(instance_id));
last_tun_route_signatures
.retain(|instance_id, _| active_tun_candidate_ids.contains(instance_id));
shrink_hash_set_if_sparse(&mut delivered_tun_requests);
shrink_hash_map_if_sparse(&mut last_tun_route_signatures);
let mut saw_running_instance = false;
let mut saw_tun_candidate = false;
for instance in snapshot.instances.iter() {
if instance.running {
saw_running_instance = true;
}
if !(instance.running && instance.tun_required) {
continue;
}
saw_tun_candidate = true;
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(&(
&virtual_ipv4,
&virtual_ipv4_cidr,
&aggregated_routes,
instance.magic_dns_enabled,
instance.need_exit_node,
))
.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) => {
ohrs_log_error!("[Rust] serialize tun request failed: {}", err);
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 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);
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);
}
}
if !delivered_tun_requests.is_empty()
|| (saw_running_instance && !saw_tun_candidate)
|| now >= tun_fast_until
{
tun_bootstrap_done = true;
}
thread::sleep(SOCKET_TICK_INTERVAL);
thread::sleep(Duration::from_millis(250));
}
});
@@ -563,7 +172,7 @@ pub fn start_local_socket_server() -> bool {
true
}
Err(err) => {
ohrs_log_error!("[Rust] lock localsocket state failed: {}", err);
hilog_error!("[Rust] lock localsocket state failed: {}", err);
false
}
}
@@ -573,7 +182,7 @@ pub fn stop_local_socket_server() -> bool {
let state = match LOCAL_SOCKET_STATE.lock() {
Ok(mut guard) => guard.take(),
Err(err) => {
ohrs_log_error!("[Rust] lock localsocket state failed: {}", err);
hilog_error!("[Rust] lock localsocket state failed: {}", err);
return false;
}
};
+34 -89
View File
@@ -1,46 +1,14 @@
macro_rules! ohrs_log_error {
($($arg:tt)*) => {{
if $crate::platform::logging::log_manager::app_log_enabled(5) {
$crate::platform::logging::log_manager::record_app_log(
5,
"RustOhrs",
&std::format!($($arg)*),
);
}
}};
}
macro_rules! ohrs_log_info {
($($arg:tt)*) => {{
if $crate::platform::logging::log_manager::app_log_enabled(4) {
$crate::platform::logging::log_manager::record_app_log(
4,
"RustOhrs",
&std::format!($($arg)*),
);
}
}};
}
macro_rules! ohrs_log_debug {
($($arg:tt)*) => {{
if $crate::platform::logging::log_manager::app_log_enabled(3) {
$crate::platform::logging::log_manager::record_app_log(
3,
"RustOhrs",
&std::format!($($arg)*),
);
}
}};
}
mod config;
mod exports;
mod kernel_bridge;
mod platform;
mod runtime;
use config::repository::{cache_runtime_config_snapshot, start_kernel_with_config_id};
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,
@@ -52,7 +20,7 @@ use config::services::share_link_service::{
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, SnapshotImportResult};
use config::types::stored_config::{KeyValuePair, SharedConfigLinkPayload};
use easytier::common::constants::EASYTIER_VERSION;
use easytier::common::{
MachineIdOptions,
@@ -63,11 +31,15 @@ use easytier::proto::api::manage::NetworkConfig;
use easytier::proto::api::manage::NetworkingMethod;
use easytier::web_client::{WebClient, WebClientHooks, run_web_client};
use kernel_bridge::{
start_local_socket_server as start_local_socket_server_inner,
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 runtime::state::runtime_state::RuntimeAggregateState;
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};
@@ -129,7 +101,7 @@ fn stop_web_client(config_id: &str) -> bool {
let managed = match WEB_CLIENTS.lock() {
Ok(mut guard) => guard.remove(config_id),
Err(err) => {
ohrs_log_error!("[Rust] stop_web_client lock failed {}", err);
hilog_error!("[Rust] stop_web_client lock failed {}", err);
return false;
}
};
@@ -155,7 +127,7 @@ fn stop_web_client(config_id: &str) -> bool {
.delete_network_instance(tracked_ids)
.map(|_| true)
.unwrap_or_else(|err| {
ohrs_log_error!(
hilog_error!(
"[Rust] stop config server instances failed {}: {}",
config_id,
err
@@ -188,12 +160,12 @@ fn run_config_server_instance(config_id: &str, config: &NetworkConfig) -> bool {
.next()
.is_some()
{
ohrs_log_error!("[Rust] there is a running instance!");
hilog_error!("[Rust] there is a running instance!");
return false;
}
let Some(config_server_url) = config.public_server_url.clone() else {
ohrs_log_error!("[Rust] public_server_url missing for config server mode");
hilog_error!("[Rust] public_server_url missing for config server mode");
return false;
};
let hooks = Arc::new(TrackedWebClientHooks::default());
@@ -220,7 +192,7 @@ fn run_config_server_instance(config_id: &str, config: &NetworkConfig) -> bool {
let client = match client {
Ok(client) => client,
Err(err) => {
ohrs_log_error!("[Rust] start config server failed {}", err);
hilog_error!("[Rust] start config server failed {}", err);
return false;
}
};
@@ -237,7 +209,7 @@ fn run_config_server_instance(config_id: &str, config: &NetworkConfig) -> bool {
true
}
Err(err) => {
ohrs_log_error!("[Rust] store config server client failed {}", err);
hilog_error!("[Rust] store config server client failed {}", err);
false
}
}
@@ -268,33 +240,29 @@ 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) => {
ohrs_log_error!("[Rust] parse config failed {}", e);
hilog_error!("[Rust] parse config failed {}", e);
return false;
}
};
if is_config_server_config(&config) {
let Some(config_id) = config.instance_id.as_deref() else {
ohrs_log_error!("[Rust] config server config missing instance id");
hilog_error!("[Rust] config server config missing instance id");
return false;
};
let started = run_config_server_instance(config_id, &config);
if started {
cache_runtime_config_snapshot(config_id.to_string(), config_id.to_string(), config);
}
return started;
return run_config_server_instance(config_id, &config);
}
let cfg = match config.gen_config() {
Ok(toml) => toml,
Err(e) => {
ohrs_log_error!("[Rust] parse config failed {}", e);
hilog_error!("[Rust] parse config failed {}", e);
return false;
}
};
if !INSTANCE_MANAGER.list_network_instance_ids().is_empty() {
ohrs_log_error!("[Rust] there is a running instance!");
hilog_error!("[Rust] there is a running instance!");
return false;
}
@@ -307,17 +275,14 @@ pub(crate) fn run_network_instance_from_json(cfg_json: &str) -> bool {
.list_network_instance_ids()
.contains(&inst_id)
{
ohrs_log_error!("[Rust] instance {} already exists", inst_id);
hilog_error!("[Rust] instance {} already exists", inst_id);
return false;
}
match INSTANCE_MANAGER.run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG) {
Ok(_) => {
cache_runtime_config_snapshot(inst_id.to_string(), inst_id.to_string(), config);
true
}
Ok(_) => true,
Err(err) => {
ohrs_log_error!("[Rust] start_kernel failed for {}: {}", inst_id, err);
hilog_error!("[Rust] start_kernel failed for {}: {}", inst_id, err);
false
}
}
@@ -327,7 +292,7 @@ fn parse_instance_uuid(config_id: &str) -> Option<Uuid> {
match Uuid::parse_str(config_id) {
Ok(uuid) => Some(uuid),
Err(err) => {
ohrs_log_error!("[Rust] invalid config_id {}: {}", config_id, err);
hilog_error!("[Rust] invalid config_id {}: {}", config_id, err);
None
}
}
@@ -338,11 +303,6 @@ pub fn init_config_store(root_dir: String) -> bool {
exports::config_api::init_config_store(root_dir)
}
#[napi]
pub fn reset_config_store() -> bool {
exports::config_api::reset_config_store()
}
#[napi]
pub fn list_configs() -> String {
exports::config_api::list_configs()
@@ -393,11 +353,6 @@ pub fn set_config_field(config_id: String, field: String, json_value: String) ->
exports::config_api::set_config_field(config_id, field, json_value)
}
#[napi]
pub fn set_config_favorite(config_id: String, favorite: bool) -> bool {
exports::config_api::set_config_favorite(config_id, favorite)
}
#[napi]
pub fn import_toml(toml_text: String, display_name: Option<String>) -> Option<String> {
exports::config_api::import_toml(toml_text, display_name)
@@ -408,21 +363,6 @@ pub fn export_toml(config_id: String) -> Option<String> {
exports::config_api::export_toml(config_id)
}
#[napi]
pub fn export_config_store_snapshot(target_path: String) -> bool {
exports::config_api::export_config_store_snapshot(target_path)
}
#[napi]
pub fn import_config_store_snapshot(source_path: String) -> bool {
exports::config_api::import_config_store_snapshot(source_path)
}
#[napi]
pub fn import_config_store_snapshot_with_result(source_path: String) -> SnapshotImportResult {
exports::config_api::import_config_store_snapshot_with_result(source_path)
}
#[napi]
pub fn start_kernel(config_id: String) -> bool {
exports::runtime_api::start_kernel(config_id, start_kernel_with_config_id)
@@ -517,8 +457,13 @@ mod tests {
}
}
pub(crate) fn collect_runtime_state_inner() -> RuntimeAggregateState {
exports::runtime_api::collect_runtime_state()
#[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]
@@ -1,393 +0,0 @@
use napi_derive_ohos::napi;
use once_cell::sync::Lazy;
use std::collections::VecDeque;
use std::fs::{self, Metadata, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
const LOG_DIR_NAME: &str = "easytier-logs";
const LOG_FILE_PREFIX: &str = "easytier-";
const LOG_FILE_SUFFIX: &str = ".log";
const MAX_LOG_FILES: usize = 10;
const MAX_MEMORY_LINES: usize = 500;
#[derive(Debug, Clone)]
#[napi(object)]
pub struct LogFileInfo {
pub file_name: String,
pub display_name: String,
pub size_bytes: i64,
pub modified_ms: i64,
pub active: bool,
}
#[derive(Clone)]
struct LogOptions {
core_log: bool,
debug_log: bool,
}
impl Default for LogOptions {
fn default() -> Self {
Self {
core_log: false,
debug_log: false,
}
}
}
#[derive(Default)]
struct LogManagerState {
log_dir: Option<PathBuf>,
active_file: Option<PathBuf>,
lines: VecDeque<String>,
options: LogOptions,
}
static LOG_MANAGER: Lazy<Mutex<LogManagerState>> =
Lazy::new(|| Mutex::new(LogManagerState::default()));
static CORE_LOG_ENABLED: AtomicBool = AtomicBool::new(false);
static DEBUG_LOG_ENABLED: AtomicBool = AtomicBool::new(false);
fn now_millis() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or(0)
}
fn sanitize_name(raw: &str) -> String {
let value = raw
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
ch
} else {
'-'
}
})
.collect::<String>();
if value.is_empty() {
"process".to_string()
} else {
value
}
}
fn log_dir(root_dir: &str) -> PathBuf {
Path::new(root_dir).join(LOG_DIR_NAME)
}
fn is_log_file(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.map(|name| name.starts_with(LOG_FILE_PREFIX) && name.ends_with(LOG_FILE_SUFFIX))
.unwrap_or(false)
}
fn sorted_log_files(dir: &Path) -> Vec<PathBuf> {
let mut files = fs::read_dir(dir)
.ok()
.into_iter()
.flat_map(|entries| entries.filter_map(|entry| entry.ok()))
.map(|entry| entry.path())
.filter(|path| is_log_file(path))
.collect::<Vec<_>>();
files.sort_by(|left, right| {
left.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default()
.cmp(
right
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default(),
)
});
files
}
fn current_log_state() -> Option<(PathBuf, Option<PathBuf>)> {
LOG_MANAGER.lock().ok().and_then(|guard| {
guard
.log_dir
.clone()
.map(|dir| (dir, guard.active_file.clone()))
})
}
fn file_name(path: &Path) -> Option<String> {
path.file_name()
.and_then(|value| value.to_str())
.map(|value| value.to_string())
}
fn latest_process_log_file(dir: &Path, process_name: &str) -> Option<PathBuf> {
let suffix = format!("-{}{}", sanitize_name(process_name), LOG_FILE_SUFFIX);
sorted_log_files(dir).into_iter().rev().find(|path| {
path.file_name()
.and_then(|value| value.to_str())
.map(|value| value.ends_with(&suffix))
.unwrap_or(false)
})
}
fn modified_millis(metadata: &Metadata) -> i64 {
metadata
.modified()
.ok()
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_millis().min(i64::MAX as u128) as i64)
.unwrap_or(0)
}
fn resolve_log_file(dir: &Path, requested_name: &str) -> Option<PathBuf> {
if requested_name.contains('/')
|| requested_name.contains('\\')
|| requested_name.contains("..")
{
return None;
}
sorted_log_files(dir).into_iter().find(|path| {
path.file_name()
.and_then(|value| value.to_str())
.map(|value| value == requested_name)
.unwrap_or(false)
})
}
fn cleanup_old_logs(dir: &Path) {
let files = sorted_log_files(dir);
let overflow = files.len().saturating_sub(MAX_LOG_FILES);
for path in files.into_iter().take(overflow) {
let _ = fs::remove_file(path);
}
}
fn push_memory_line(state: &mut LogManagerState, line: String) {
state.lines.push_back(line);
while state.lines.len() > MAX_MEMORY_LINES {
state.lines.pop_front();
}
}
fn append_log_file(path: &Path, line: &str) {
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
let _ = writeln!(file, "{}", line);
}
}
fn should_record_debug(level: i32) -> bool {
level <= 3
}
fn format_line(level: i32, target: &str, message: &str) -> String {
format!("{}[{}] {}", level, target, message.replace('\n', "\\n"))
}
pub(crate) fn configure(core_log: bool, debug_log: bool) {
CORE_LOG_ENABLED.store(core_log, Ordering::Relaxed);
DEBUG_LOG_ENABLED.store(debug_log, Ordering::Relaxed);
if let Ok(mut guard) = LOG_MANAGER.lock() {
guard.options.core_log = core_log;
guard.options.debug_log = debug_log;
}
}
pub(crate) fn app_log_enabled(level: i32) -> bool {
!should_record_debug(level) || DEBUG_LOG_ENABLED.load(Ordering::Relaxed)
}
pub(crate) fn core_log_enabled(level: i32) -> bool {
CORE_LOG_ENABLED.load(Ordering::Relaxed) && app_log_enabled(level)
}
pub(crate) fn record_app_log(level: i32, target: &str, message: &str) {
if !app_log_enabled(level) {
return;
}
if let Ok(mut guard) = LOG_MANAGER.lock() {
let line = format_line(level, target, message);
if let Some(path) = guard.active_file.as_ref() {
append_log_file(path, &line);
}
push_memory_line(&mut guard, line);
}
}
pub(crate) fn record_core_log(level: i32, target: &str, message: &str) {
if !core_log_enabled(level) {
return;
}
if let Ok(mut guard) = LOG_MANAGER.lock() {
let line = format_line(level, target, message);
if let Some(path) = guard.active_file.as_ref() {
append_log_file(path, &line);
}
push_memory_line(&mut guard, line);
}
}
#[napi]
pub fn init_log_manager(root_dir: String, process_name: String) -> bool {
let dir = log_dir(&root_dir);
if fs::create_dir_all(&dir).is_err() {
return false;
}
if LOG_MANAGER
.lock()
.map(|guard| guard.active_file.is_some())
.unwrap_or(false)
{
cleanup_old_logs(&dir);
return true;
}
let sanitized_process_name = sanitize_name(&process_name);
let active_file = if sanitized_process_name == "ui" {
dir.join(format!(
"{}{}-{}-{}{}",
LOG_FILE_PREFIX,
now_millis(),
std::process::id(),
sanitized_process_name,
LOG_FILE_SUFFIX
))
} else if let Some(path) = latest_process_log_file(&dir, "ui") {
path
} else {
dir.join(format!(
"{}{}-{}-{}{}",
LOG_FILE_PREFIX,
now_millis(),
std::process::id(),
sanitized_process_name,
LOG_FILE_SUFFIX
))
};
if OpenOptions::new()
.create(true)
.append(true)
.open(&active_file)
.is_err()
{
return false;
}
if let Ok(mut guard) = LOG_MANAGER.lock() {
guard.log_dir = Some(dir.clone());
guard.active_file = Some(active_file);
guard.lines.clear();
}
cleanup_old_logs(&dir);
true
}
#[napi]
pub fn configure_log_manager(core_log: bool, debug_log: bool) {
configure(core_log, debug_log);
}
#[napi]
pub fn write_app_log(level: i32, target: String, message: String) {
record_app_log(level, &target, &message);
}
#[napi]
pub fn drain_log_lines() -> Vec<String> {
LOG_MANAGER
.lock()
.map(|mut guard| guard.lines.drain(..).collect())
.unwrap_or_default()
}
#[napi]
pub fn list_log_files() -> Vec<LogFileInfo> {
let Some((log_dir, active_file)) = current_log_state() else {
return Vec::new();
};
let active_name = active_file.as_ref().and_then(|path| file_name(path));
let mut files = sorted_log_files(&log_dir);
files.reverse();
files
.into_iter()
.filter_map(|path| {
let file_name = file_name(&path)?;
let active = active_name
.as_ref()
.map(|name| name == &file_name)
.unwrap_or(false);
let metadata = fs::metadata(&path).ok();
Some(LogFileInfo {
file_name,
display_name: if active {
"当前启动日志".to_string()
} else {
"历史日志".to_string()
},
size_bytes: metadata
.as_ref()
.map(|value| value.len().min(i64::MAX as u64) as i64)
.unwrap_or(0),
modified_ms: metadata.as_ref().map(modified_millis).unwrap_or_default(),
active,
})
})
.collect()
}
#[napi]
pub fn read_log_file(file_name: String) -> Option<String> {
let (log_dir, _) = current_log_state()?;
let path = resolve_log_file(&log_dir, &file_name)?;
fs::read_to_string(path).ok()
}
#[napi]
pub fn export_log_file(file_name: String, target_path: String) -> bool {
let Some((log_dir, _)) = current_log_state() else {
return false;
};
let Some(path) = resolve_log_file(&log_dir, &file_name) else {
return false;
};
fs::copy(path, target_path).is_ok()
}
#[napi]
pub fn export_log_archive(target_path: String) -> bool {
let log_dir = LOG_MANAGER
.lock()
.ok()
.and_then(|guard| guard.log_dir.clone());
let Some(log_dir) = log_dir else {
return false;
};
let files = sorted_log_files(&log_dir);
let mut output = match OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&target_path)
{
Ok(file) => file,
Err(_) => return false,
};
for path in files {
let name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("unknown.log");
let _ = writeln!(output, "===== {} =====", name);
if let Ok(content) = fs::read_to_string(&path) {
let _ = writeln!(output, "{}", content);
}
}
true
}
@@ -1,2 +1 @@
pub(crate) mod log_manager;
pub(crate) mod native_log;
@@ -1,5 +1,7 @@
use super::log_manager;
use napi_derive_ohos::napi;
use ohos_hilog_binding::{
LogOptions, hilog_debug, hilog_error, hilog_info, hilog_warn, set_global_options,
};
use std::collections::HashMap;
use std::panic;
use tracing::{Event, Subscriber};
@@ -8,9 +10,8 @@ use tracing_subscriber::layer::{Context, Layer};
use tracing_subscriber::prelude::*;
static INITIALIZED: std::sync::Once = std::sync::Once::new();
static TRACING_INITIALIZED: std::sync::Once = std::sync::Once::new();
fn panic_hook(info: &panic::PanicHookInfo) {
log_manager::record_core_log(5, "RustPanic", &format!("{}", info));
hilog_error!("RUST PANIC: {}", info);
}
#[napi]
@@ -22,40 +23,45 @@ pub fn init_panic_hook() {
#[napi]
pub fn hilog_global_options(domain: u32, tag: String) {
let _ = domain;
let _ = tag;
ohos_hilog_binding::forward_stdio_to_hilog();
set_global_options(LogOptions {
domain,
tag: Box::leak(tag.clone().into_boxed_str()),
})
}
#[napi]
pub fn init_tracing_subscriber() {
TRACING_INITIALIZED.call_once(|| {
let _ = tracing_subscriber::registry()
.with(CallbackLayer {
callback: Box::new(tracing_callback),
})
.try_init();
});
tracing_subscriber::registry()
.with(CallbackLayer {
callback: Box::new(tracing_callback),
})
.init();
}
fn tracing_callback(event: &Event, fields: HashMap<String, String>) {
let metadata = event.metadata();
let loc = metadata
.target()
.split("::")
.last()
.unwrap_or(metadata.target());
let level = match *metadata.level() {
Level::TRACE => 2,
Level::DEBUG => 3,
Level::INFO => 4,
Level::WARN => 6,
Level::ERROR => 5,
};
if !log_manager::core_log_enabled(level) {
return;
#[cfg(target_env = "ohos")]
{
let loc = metadata.target().split("::").last().unwrap();
match *metadata.level() {
Level::TRACE => {
hilog_debug!("[{}] {:?}", loc, fields.values().collect::<Vec<_>>());
}
Level::DEBUG => {
hilog_debug!("[{}] {:?}", loc, fields.values().collect::<Vec<_>>());
}
Level::INFO => {
hilog_info!("[{}] {:?}", loc, fields.values().collect::<Vec<_>>());
}
Level::WARN => {
hilog_warn!("[{}] {:?}", loc, fields.values().collect::<Vec<_>>());
}
Level::ERROR => {
hilog_error!("[{}] {:?}", loc, fields.values().collect::<Vec<_>>());
}
}
}
let values = fields.values().cloned().collect::<Vec<_>>().join(" ");
log_manager::record_core_log(level, &format!("Rust:{}", loc), &values);
}
struct CallbackLayer {
@@ -64,16 +70,6 @@ struct CallbackLayer {
impl<S: Subscriber> Layer<S> for CallbackLayer {
fn on_event(&self, event: &Event, _ctx: Context<S>) {
let level = match *event.metadata().level() {
Level::TRACE => 2,
Level::DEBUG => 3,
Level::INFO => 4,
Level::WARN => 6,
Level::ERROR => 5,
};
if !log_manager::core_log_enabled(level) {
return;
}
// 使用 fmt::format::FmtSpan 提取字段值
let mut fields = HashMap::new();
let mut visitor = FieldCollector(&mut fields);
@@ -3,7 +3,6 @@ use napi_derive_ohos::napi;
use serde::Serialize;
use std::collections::HashSet;
use std::sync::Mutex;
use url::Url;
static ATTACHED_TUN_INSTANCE_IDS: once_cell::sync::Lazy<Mutex<HashSet<String>>> =
once_cell::sync::Lazy::new(|| Mutex::new(HashSet::new()));
@@ -159,136 +158,6 @@ fn stringify_uuid(value: Option<common::Uuid>) -> Option<String> {
value.map(|v| v.to_string())
}
fn non_empty_string(value: Option<String>) -> Option<String> {
value.and_then(|raw| {
let trimmed = raw.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
}
fn config_virtual_ipv4_cidr(config: &api::manage::NetworkConfig) -> Option<String> {
non_empty_string(config.virtual_ipv4.clone())
.map(|ipv4| format!("{}/{}", ipv4, config.network_length.unwrap_or(24)))
}
fn config_endpoint_urls(config: &api::manage::NetworkConfig) -> Vec<String> {
let mut urls = Vec::new();
let mut seen = HashSet::new();
if let Some(url) = non_empty_string(config.public_server_url.clone())
&& seen.insert(url.clone())
{
urls.push(url);
}
for raw in &config.peer_urls {
let trimmed = raw.trim();
if trimmed.is_empty() {
continue;
}
let value = trimmed.to_string();
if seen.insert(value.clone()) {
urls.push(value);
}
}
urls
}
fn endpoint_url(url: &str) -> Option<Url> {
Url::parse(url).ok()
}
fn endpoint_scheme(url: &str) -> Option<String> {
endpoint_url(url)
.map(|parsed| parsed.scheme().to_string())
.or_else(|| {
let scheme = url.split("://").next().unwrap_or("").trim();
(!scheme.is_empty()).then_some(scheme.to_string())
})
}
fn endpoint_label(url: &str) -> String {
if let Some(parsed) = endpoint_url(url)
&& let Some(host) = parsed.host_str()
{
return format!("[Config] {}", host);
}
format!("[Config] {}", url)
}
fn endpoint_remote_display(url: &str) -> String {
if let Some(parsed) = endpoint_url(url)
&& let Some(host) = parsed.host_str()
{
return parsed
.port()
.map(|port| format!("{}:{}", host, port))
.unwrap_or_else(|| host.to_string());
}
url.to_string()
}
fn configured_peer_id(index: usize) -> i64 {
9_000_000 + index as i64
}
fn configured_route_views(endpoints: &[String], public_server_url: Option<&str>) -> Vec<RouteView> {
endpoints
.iter()
.enumerate()
.map(|(index, endpoint)| RouteView {
peer_id: configured_peer_id(index),
hostname: Some(endpoint_label(endpoint)),
ipv4: Some(endpoint_remote_display(endpoint)),
ipv4_cidr: None,
ipv6_cidr: None,
proxy_cidrs: Vec::new(),
next_hop_peer_id: None,
cost: Some(0),
path_latency: None,
udp_nat_type: None,
tcp_nat_type: None,
inst_id: None,
version: None,
is_public_server: public_server_url.map(|url| url == endpoint),
})
.collect()
}
fn configured_peer_views(endpoints: &[String]) -> Vec<PeerInfo> {
endpoints
.iter()
.enumerate()
.map(|(index, endpoint)| {
let conn_id = format!("configured-peer-{}", index);
PeerInfo {
peer_id: configured_peer_id(index),
default_conn_id: Some(conn_id.clone()),
directly_connected_conns: vec![conn_id.clone()],
conns: vec![PeerConnInfo {
conn_id,
my_peer_id: 0,
peer_id: configured_peer_id(index),
features: Vec::new(),
tunnel_type: endpoint_scheme(endpoint),
local_addr: None,
remote_addr: Some(endpoint.clone()),
resolved_remote_addr: Some(endpoint_remote_display(endpoint)),
stats: None,
loss_rate: None,
is_client: true,
network_name: None,
is_closed: false,
secure_auth_level: None,
peer_identity_type: None,
}],
}
})
.collect()
}
fn optional_u32_to_i64(value: Option<u32>) -> Option<i64> {
value.map(|v| v as i64)
}
@@ -324,7 +193,7 @@ fn route_to_view(route: api::instance::Route) -> RouteView {
}
}
pub(crate) fn peer_conn_to_view(conn: api::instance::PeerConnInfo) -> PeerConnInfo {
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,
@@ -422,43 +291,3 @@ pub fn runtime_instance_from_running_info(
peers: info.peers.into_iter().map(peer_to_view).collect(),
}
}
pub fn runtime_instance_from_config_snapshot(
config_id: String,
display_name: String,
config: api::manage::NetworkConfig,
running: bool,
) -> RuntimeInstanceState {
let tun_attached = running && is_tun_attached(&config_id);
let tun_required =
running && (config.dev_name.as_deref().unwrap_or("") != "no_tun" || tun_attached);
let endpoint_urls = config_endpoint_urls(&config);
let public_server_url = non_empty_string(config.public_server_url.clone());
let my_node_info = MyNodeInfo {
virtual_ipv4: non_empty_string(config.virtual_ipv4.clone()),
virtual_ipv4_cidr: config_virtual_ipv4_cidr(&config),
hostname: non_empty_string(config.hostname.clone()),
version: None,
peer_id: None,
listeners: config.listener_urls.clone(),
vpn_portal_cfg: None,
udp_nat_type: None,
tcp_nat_type: None,
};
RuntimeInstanceState {
config_id: config_id.clone(),
instance_id: config_id,
display_name,
running,
tun_required,
tun_attached,
magic_dns_enabled: config.enable_magic_dns.unwrap_or(false),
need_exit_node: !config.exit_nodes.is_empty(),
error_message: None,
my_node_info: Some(my_node_info),
events: Vec::new(),
routes: configured_route_views(&endpoint_urls, public_server_url.as_deref()),
peers: configured_peer_views(&endpoint_urls),
}
}
+26 -49
View File
@@ -15,9 +15,7 @@ use easytier::rpc_service::remote_client::{
use easytier::web_client::{self, WebClient};
use easytier::{
common::{
config::{
ConfigLoader, ConfigSource, FileLoggerConfig, LoggingConfigBuilder, TomlConfigLoader,
},
config::{ConfigLoader, ConfigSource, FileLoggerConfig, LoggingConfig, TomlConfigLoader},
log,
},
instance_manager::NetworkInstanceManager,
@@ -654,8 +652,7 @@ mod manager {
#[derive(Default)]
pub(super) enum PersistedConfigSource {
User,
#[serde(alias = "webhook")]
Web,
Webhook,
#[serde(other)]
#[default]
Legacy,
@@ -665,15 +662,15 @@ mod manager {
pub(super) fn from_runtime_source(source: ConfigSource) -> Self {
match source {
ConfigSource::User => Self::User,
ConfigSource::Web => Self::Web,
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 web sync or an explicit user save repairs it.
(Self::Web, Self::User) | (Self::Legacy, Self::User) => self,
// ownership until webhook sync or an explicit user save repairs it.
(Self::Webhook, Self::User) | (Self::Legacy, Self::User) => self,
(_, next) => next,
}
}
@@ -681,13 +678,13 @@ mod manager {
fn to_runtime_source(self) -> ConfigSource {
match self {
Self::User | Self::Legacy => ConfigSource::User,
Self::Web => ConfigSource::Web,
Self::Webhook => ConfigSource::Webhook,
}
}
#[cfg(any(test, target_os = "android"))]
fn is_web_like(self) -> bool {
matches!(self, Self::Web)
fn is_webhook_like(self) -> bool {
matches!(self, Self::Webhook)
}
}
@@ -919,7 +916,7 @@ mod manager {
}
#[cfg(target_os = "android")]
pub fn get_enabled_instances_with_web_like_tun_ids(
pub fn get_enabled_instances_with_webhook_like_tun_ids(
&self,
) -> impl Iterator<Item = uuid::Uuid> + '_ {
self.storage
@@ -927,7 +924,7 @@ mod manager {
.iter()
.filter(|v| self.storage.enabled_networks.contains(v.key()))
.filter(|v| !v.config.no_tun())
.filter(|v| v.source.is_web_like())
.filter(|v| v.source.is_webhook_like())
.filter_map(|c| c.config.instance_id().parse::<uuid::Uuid>().ok())
}
@@ -935,11 +932,12 @@ mod manager {
pub(super) async fn disable_instances_with_tun(
&self,
app: &AppHandle,
web_only: bool,
webhook_only: bool,
) -> Result<(), easytier::rpc_service::remote_client::RemoteClientError<anyhow::Error>>
{
let inst_ids: Vec<uuid::Uuid> = if web_only {
self.get_enabled_instances_with_web_like_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()
};
@@ -977,7 +975,7 @@ mod manager {
.await
.map_err(|e| e.to_string())?;
}
PersistedConfigSource::Web => {
PersistedConfigSource::Webhook => {
self.disable_instances_with_tun(app, true)
.await
.map_err(|e| e.to_string())?;
@@ -1187,46 +1185,26 @@ mod manager {
}
#[test]
fn stored_gui_config_deserializes_webhook_source_as_web() {
let stored: StoredGuiConfig = serde_json::from_value(serde_json::json!({
"config": NetworkConfig::default(),
"source": "webhook",
}))
.unwrap();
assert_eq!(stored.source, PersistedConfigSource::Web);
}
#[test]
fn stored_gui_config_defaults_unknown_source_to_legacy() {
let stored: StoredGuiConfig = serde_json::from_value(serde_json::json!({
"config": NetworkConfig::default(),
"source": "unknown",
}))
.unwrap();
assert_eq!(stored.source, PersistedConfigSource::Legacy);
}
#[test]
fn persisted_source_merge_keeps_legacy_and_web_over_ambiguous_user() {
fn persisted_source_merge_keeps_legacy_and_webhook_over_ambiguous_user() {
assert_eq!(
PersistedConfigSource::Legacy.merge_persisted(PersistedConfigSource::User),
PersistedConfigSource::Legacy
);
assert_eq!(
PersistedConfigSource::Web.merge_persisted(PersistedConfigSource::User),
PersistedConfigSource::Web
PersistedConfigSource::Webhook.merge_persisted(PersistedConfigSource::User),
PersistedConfigSource::Webhook
);
assert_eq!(
PersistedConfigSource::Legacy.merge_persisted(PersistedConfigSource::Web),
PersistedConfigSource::Web
PersistedConfigSource::Legacy.merge_persisted(PersistedConfigSource::Webhook),
PersistedConfigSource::Webhook
);
}
#[test]
fn only_web_configs_are_web_like() {
assert!(!PersistedConfigSource::Legacy.is_web_like());
assert!(!PersistedConfigSource::User.is_web_like());
assert!(PersistedConfigSource::Web.is_web_like());
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());
}
}
}
@@ -1346,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,
@@ -1354,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(());
};
+4 -3
View File
@@ -1,11 +1,12 @@
import { invoke } from '@tauri-apps/api/core'
import { Api, NetworkTypes } from 'easytier-frontend-lib'
import { GetNetworkMetasResponse } from 'node_modules/easytier-frontend-lib/dist/modules/api'
import { type ConfigSource, normalizeConfigSource } from './config_source'
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
@@ -31,14 +32,14 @@ function parseStoredConfigs(raw: string | null): StoredGuiConfig[] {
if (entry && typeof entry === 'object' && 'config' in entry) {
const { config, source } = entry as {
config?: NetworkConfig
source?: unknown
source?: ConfigSource
}
if (!config) {
return []
}
return [{
config: NetworkTypes.normalizeNetworkConfig(config),
source: normalizeConfigSource(source),
source: source === 'user' || source === 'webhook' ? source : 'legacy',
}]
}
@@ -1,13 +0,0 @@
export type ConfigSource = 'user' | 'web' | 'legacy'
export function normalizeConfigSource(source: unknown): ConfigSource {
if (source === 'user' || source === 'web' || source === 'legacy') {
return source
}
if (source === 'webhook') {
return 'web'
}
return 'legacy'
}
+2 -3
View File
@@ -2,11 +2,10 @@ import { Event, listen } from "@tauri-apps/api/event";
import { type } from "@tauri-apps/plugin-os";
import { NetworkTypes } from "easytier-frontend-lib"
import { Utils } from "easytier-frontend-lib";
import { normalizeConfigSource } from './config_source'
interface StoredGuiConfig {
config: NetworkTypes.NetworkConfig
source?: unknown
source?: 'user' | 'webhook' | 'legacy'
}
const EVENTS = Object.freeze({
@@ -25,7 +24,7 @@ function onSaveConfigs(event: Event<StoredGuiConfig[]>) {
'networkList',
JSON.stringify(event.payload.map(({ config, source }) => ({
config: NetworkTypes.normalizeNetworkConfig(config),
source: normalizeConfigSource(source),
source: source ?? 'legacy',
}))),
);
}
-1
View File
@@ -53,7 +53,6 @@ clap = { version = "4.4.8", features = [
"unicode",
"derive",
"wrap_help",
"env",
] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
+15 -18
View File
@@ -20,7 +20,7 @@ use session::{Location, Session};
use storage::{Storage, StorageToken};
use crate::FeatureFlags;
use crate::webhook::{ManagedNetworkConfig, SharedWebhookConfig};
use crate::webhook::SharedWebhookConfig;
use tokio::task::JoinSet;
use crate::db::{Db, UserIdInDb, entity::user_running_network_configs};
@@ -146,7 +146,20 @@ impl ClientManager {
}
pub async fn list_sessions(&self) -> Vec<StorageToken> {
self.storage.list_clients()
let sessions = self
.client_sessions
.iter()
.map(|item| item.value().clone())
.collect::<Vec<_>>();
let mut ret: Vec<StorageToken> = vec![];
for s in sessions {
if let Some(t) = s.get_token().await {
ret.push(t);
}
}
ret
}
pub fn get_session_by_machine_id(
@@ -184,22 +197,6 @@ impl ClientManager {
self.storage.list_user_clients(user_id)
}
pub async fn reconcile_managed_network_configs(
&self,
user_id: UserIdInDb,
machine_id: uuid::Uuid,
desired_configs: Vec<ManagedNetworkConfig>,
) -> anyhow::Result<()> {
session::SessionRpcService::reconcile_web_source_configs(
&self.storage,
user_id,
machine_id,
desired_configs,
)
.await?;
Ok(())
}
pub async fn get_heartbeat_requests(&self, client_url: &url::Url) -> Option<HeartbeatRequest> {
let s = self.client_sessions.get(client_url)?.clone();
s.data().read().await.req()
File diff suppressed because it is too large Load Diff
@@ -114,20 +114,6 @@ impl Storage {
.unwrap_or_default()
}
pub fn list_clients(&self) -> Vec<StorageToken> {
self.0
.user_clients_map
.iter()
.flat_map(|user_clients| {
user_clients
.value()
.iter()
.map(|info| info.value().storage_token.clone())
.collect::<Vec<_>>()
})
.collect()
}
pub fn db(&self) -> &Db {
&self.0.db
}
@@ -188,25 +174,4 @@ mod tests {
assert_eq!(storage.get_client_url_by_machine_id(2, &machine_id), None);
}
#[tokio::test]
async fn list_clients_returns_current_storage_tokens() {
let storage = Storage::new(Db::memory_db().await);
let user1_token = make_storage_token(1, uuid::Uuid::new_v4(), "tcp://127.0.0.1:1001");
let user2_token = make_storage_token(2, uuid::Uuid::new_v4(), "tcp://127.0.0.1:1002");
storage.update_client(user1_token.clone(), 10);
storage.update_client(user2_token.clone(), 20);
let tokens = storage.list_clients();
assert_eq!(tokens.len(), 2);
assert!(tokens.iter().any(|token| token.token == user1_token.token));
assert!(tokens.iter().any(|token| token.token == user2_token.token));
storage.remove_client(&user1_token);
let tokens = storage.list_clients();
assert_eq!(tokens.len(), 1);
assert_eq!(tokens[0].token, user2_token.token);
}
}
+6 -6
View File
@@ -331,7 +331,7 @@ mod tests {
(user_id, device_id),
inst_id,
network_config,
ConfigSource::Web,
ConfigSource::Webhook,
)
.await
.unwrap();
@@ -344,10 +344,10 @@ mod tests {
.unwrap();
println!("device: {}, {:?}", device_id, result2);
assert_eq!(result2.network_config, network_config_json);
assert_eq!(result2.get_network_config_source(), ConfigSource::Web);
assert_eq!(result2.get_network_config_source(), ConfigSource::Webhook);
assert_eq!(
result2.get_runtime_network_config_source(),
ConfigSource::Web
ConfigSource::Webhook
);
assert_eq!(result.create_time, result2.create_time);
@@ -373,7 +373,7 @@ mod tests {
}
#[tokio::test]
async fn test_unknown_network_config_source_defaults_to_user_runtime_source() {
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();
@@ -384,11 +384,11 @@ mod tests {
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("unknown-source".to_string()),
network_name: Some("legacy".to_string()),
..Default::default()
})
.unwrap()),
source: Set("unknown".to_string()),
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()),
+8 -36
View File
@@ -41,32 +41,23 @@ rust_i18n::i18n!("locales", fallback = "en");
#[derive(Parser, Debug)]
#[command(name = "easytier-web", author, version = EASYTIER_VERSION , about, long_about = None)]
struct Cli {
#[arg(
short,
long,
env = "ET_WEB_DB",
default_value = "et.db",
help = t!("cli.db").to_string()
)]
#[arg(short, long, default_value = "et.db", help = t!("cli.db").to_string())]
db: String,
#[arg(
long,
env = "ET_WEB_CONSOLE_LOG_LEVEL",
help = t!("cli.console_log_level").to_string(),
)]
console_log_level: Option<String>,
#[arg(
long,
env = "ET_WEB_FILE_LOG_LEVEL",
help = t!("cli.file_log_level").to_string(),
)]
file_log_level: Option<String>,
#[arg(
long,
env = "ET_WEB_FILE_LOG_DIR",
help = t!("cli.file_log_dir").to_string(),
)]
file_log_dir: Option<String>,
@@ -74,7 +65,6 @@ struct Cli {
#[arg(
long,
short='c',
env = "ET_CONFIG_SERVER_PORT",
default_value = "22020",
help = t!("cli.config_server_port").to_string(),
)]
@@ -83,7 +73,6 @@ struct Cli {
#[arg(
long,
short='p',
env = "ET_CONFIG_SERVER_PROTOCOL",
default_value = "udp",
help = t!("cli.config_server_protocol").to_string(),
)]
@@ -92,7 +81,6 @@ struct Cli {
#[arg(
long,
short='a',
env = "ET_API_SERVER_PORT",
default_value = "11211",
help = t!("cli.api_server_port").to_string(),
)]
@@ -100,7 +88,6 @@ struct Cli {
#[arg(
long,
env = "ET_API_SERVER_ADDR",
default_value = "0.0.0.0",
help = t!("cli.api_server_addr").to_string(),
)]
@@ -108,7 +95,6 @@ struct Cli {
#[arg(
long,
env = "ET_GEOIP_DB",
help = t!("cli.geoip_db").to_string(),
)]
geoip_db: Option<String>,
@@ -117,7 +103,6 @@ struct Cli {
#[arg(
long,
short='l',
env = "ET_WEB_SERVER_PORT",
help = t!("cli.web_server_port").to_string(),
)]
web_server_port: Option<u16>,
@@ -125,7 +110,6 @@ struct Cli {
#[cfg(feature = "embed")]
#[arg(
long,
env = "ET_WEB_SERVER_ADDR",
default_value = "0.0.0.0",
help = t!("cli.web_server_addr").to_string(),
)]
@@ -134,7 +118,6 @@ struct Cli {
#[cfg(feature = "embed")]
#[arg(
long,
env = "ET_NO_WEB",
help = t!("cli.no_web").to_string(),
default_value = "false"
)]
@@ -143,7 +126,6 @@ struct Cli {
#[cfg(feature = "embed")]
#[arg(
long,
env = "ET_API_HOST",
help = t!("cli.api_host").to_string()
)]
api_host: Option<url::Url>,
@@ -162,45 +144,35 @@ struct Cli {
pub struct WebhookOptions {
/// Base URL of the webhook endpoint for token validation and event delivery.
/// When set, incoming tokens are validated via this webhook before local fallback.
#[arg(long, env = "ET_WEBHOOK_URL")]
#[arg(long)]
pub webhook_url: Option<String>,
/// Shared secret used to authenticate outbound webhook calls.
#[arg(long, env = "ET_WEBHOOK_SECRET", hide_env_values = true)]
#[arg(long)]
pub webhook_secret: Option<String>,
/// Token for X-Internal-Auth header. When set, API requests with this header
/// bypass session authentication.
#[arg(long, env = "ET_INTERNAL_AUTH_TOKEN", hide_env_values = true)]
#[arg(long)]
pub internal_auth_token: Option<String>,
/// Stable identifier for this easytier-web instance when routing webhook callbacks.
#[arg(long, env = "ET_WEB_INSTANCE_ID")]
#[arg(long)]
pub web_instance_id: Option<String>,
/// Reachable base URL for this easytier-web instance's internal REST API.
#[arg(long, env = "ET_WEB_INSTANCE_API_BASE_URL")]
#[arg(long)]
pub web_instance_api_base_url: Option<String>,
}
#[derive(Debug, Clone, Default, clap::Args)]
pub struct FeatureFlags {
/// Whether user registration via the web UI is disabled.
#[arg(
long,
env = "ET_DISABLE_REGISTRATION",
default_value = "false",
help = t!("cli.disable_registration").to_string()
)]
#[arg(long, default_value = "false", help = t!("cli.disable_registration").to_string())]
pub disable_registration: bool,
/// Whether to auto-create users when they connect via heartbeat with an unknown token.
#[arg(
long,
env = "ET_ALLOW_AUTO_CREATE_USER",
default_value = "false",
help = t!("cli.allow_auto_create_user").to_string()
)]
#[arg(long, default_value = "false", help = t!("cli.allow_auto_create_user").to_string())]
pub allow_auto_create_user: bool,
}
@@ -48,7 +48,7 @@ impl MigrationTrait for Migration {
device_id,
network_instance_id,
network_config,
'user',
'legacy',
disabled,
create_time,
update_time
@@ -1,42 +0,0 @@
use sea_orm_migration::prelude::*;
pub struct Migration;
impl MigrationName for Migration {
fn name(&self) -> &str {
"m20260514_000004_rename_web_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#"
UPDATE user_running_network_configs
SET source = 'web'
WHERE source = 'webhook';
UPDATE user_running_network_configs
SET source = 'user'
WHERE source = 'legacy';
"#,
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
db.execute_unprepared(
r#"
UPDATE user_running_network_configs
SET source = 'webhook'
WHERE source = 'web';
"#,
)
.await?;
Ok(())
}
}
-2
View File
@@ -3,7 +3,6 @@ use sea_orm_migration::prelude::*;
mod m20241029_000001_init;
mod m20260403_000002_scope_network_config_unique;
mod m20260421_000003_add_network_config_source;
mod m20260514_000004_rename_web_config_source;
pub struct Migrator;
@@ -14,7 +13,6 @@ impl MigratorTrait for Migrator {
Box::new(m20241029_000001_init::Migration),
Box::new(m20260403_000002_scope_network_config_unique::Migration),
Box::new(m20260421_000003_add_network_config_source::Migration),
Box::new(m20260514_000004_rename_web_config_source::Migration),
]
}
}
+3 -53
View File
@@ -3,7 +3,6 @@ use axum::http::StatusCode;
use axum::routing::{delete, post};
use axum::{Json, Router, extract::State, routing::get};
use axum_login::AuthUser;
use easytier::common::config::ConfigSource as RuntimeConfigSource;
use easytier::launcher::NetworkConfig;
use easytier::proto::common::Void;
use easytier::proto::{api::manage::*, web::*};
@@ -61,7 +60,6 @@ struct SaveNetworkJsonReq {
struct RunNetworkJsonReq {
config: NetworkConfig,
save: bool,
source: Option<i32>,
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
@@ -84,17 +82,6 @@ struct RemoveNetworkJsonReq {
inst_ids: Vec<uuid::Uuid>,
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
struct ManagedNetworkConfigJson {
instance_id: uuid::Uuid,
network_config: serde_json::Value,
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
struct ReconcileManagedNetworkConfigsJsonReq {
managed_network_configs: Vec<ManagedNetworkConfigJson>,
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
struct ListMachineItem {
client_url: Option<url::Url>,
@@ -143,11 +130,10 @@ impl NetworkApi {
Json(payload): Json<RunNetworkJsonReq>,
) -> Result<Json<Void>, HttpHandleError> {
client_mgr
.handle_run_network_instance_with_source(
.handle_run_network_instance(
(Self::get_user_id(&auth_session)?, machine_id),
payload.config,
payload.save,
RuntimeConfigSource::Web,
)
.await
.map_err(convert_error)?;
@@ -288,11 +274,10 @@ impl NetworkApi {
));
}
client_mgr
.handle_save_network_config_with_source(
.handle_save_network_config(
(Self::get_user_id(&auth_session)?, machine_id),
inst_id,
payload.config,
RuntimeConfigSource::Web,
)
.await
.map_err(convert_error)
@@ -317,17 +302,8 @@ impl NetworkApi {
Path((user_id, machine_id)): Path<(UserIdInDb, uuid::Uuid)>,
Json(payload): Json<RunNetworkJsonReq>,
) -> Result<Json<Void>, HttpHandleError> {
let source = payload
.source
.and_then(RuntimeConfigSource::from_rpc)
.unwrap_or(RuntimeConfigSource::Web);
client_mgr
.handle_run_network_instance_with_source(
(user_id, machine_id),
payload.config,
payload.save,
source,
)
.handle_run_network_instance((user_id, machine_id), payload.config, payload.save)
.await
.map_err(convert_error)?;
Ok(Void::default().into())
@@ -343,31 +319,6 @@ impl NetworkApi {
.map_err(convert_error)
}
async fn handle_reconcile_managed_network_configs_internal(
State(client_mgr): AppState,
Path((user_id, machine_id)): Path<(UserIdInDb, uuid::Uuid)>,
Json(payload): Json<ReconcileManagedNetworkConfigsJsonReq>,
) -> Result<Json<Void>, HttpHandleError> {
let desired = payload
.managed_network_configs
.into_iter()
.map(|item| crate::webhook::ManagedNetworkConfig {
instance_id: item.instance_id.to_string(),
network_config: item.network_config,
})
.collect();
client_mgr
.reconcile_managed_network_configs(user_id, machine_id, desired)
.await
.map_err(|err| {
(
StatusCode::INTERNAL_SERVER_ERROR,
other_error(err.to_string()).into(),
)
})?;
Ok(Void::default().into())
}
async fn handle_list_network_instance_ids_internal(
State(client_mgr): AppState,
Path((user_id, machine_id)): Path<(UserIdInDb, uuid::Uuid)>,
@@ -396,7 +347,6 @@ impl NetworkApi {
.route(
"/api/internal/users/:user-id/machines/:machine-id/networks",
post(Self::handle_run_network_instance_internal)
.put(Self::handle_reconcile_managed_network_configs_internal)
.get(Self::handle_list_network_instance_ids_internal),
)
.route(
+6 -16
View File
@@ -16,7 +16,6 @@ pub struct ProxyRpcRequest {
pub service_name: String,
pub method_name: String,
pub payload: serde_json::Value,
pub scope: Option<String>,
}
macro_rules! match_service {
@@ -36,7 +35,6 @@ async fn handle_proxy_rpc_by_session(
service_name,
method_name,
payload,
scope,
} = req;
let resp = match service_name.as_str() {
@@ -76,20 +74,12 @@ async fn handle_proxy_rpc_by_session(
payload,
session
),
"api.instance.TcpProxyRpcService" => {
let client = if let Some(ref domain) = scope {
session.scoped_client_with_domain::<
easytier::proto::api::instance::TcpProxyRpcClientFactory<BaseController>,
>(domain.clone())
} else {
session.scoped_client::<
easytier::proto::api::instance::TcpProxyRpcClientFactory<BaseController>,
>()
};
client
.json_call_method(BaseController::default(), &method_name, payload)
.await
}
"api.instance.TcpProxyRpcService" => match_service!(
easytier::proto::api::instance::TcpProxyRpcClientFactory<BaseController>,
method_name,
payload,
session
),
"api.instance.AclManageRpcService" => match_service!(
easytier::proto::api::instance::AclManageRpcClientFactory<BaseController>,
method_name,
+1 -18
View File
@@ -57,8 +57,6 @@ pub struct ValidateTokenRequest {
pub os_distribution: Option<String>,
pub web_instance_id: Option<String>,
pub web_instance_api_base_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub applied_config_revision: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -68,8 +66,7 @@ pub struct ValidateTokenResponse {
pub pre_approved: bool,
#[serde(default)]
pub binding_version: u64,
#[serde(default)]
pub managed_network_configs: Option<Vec<ManagedNetworkConfig>>,
pub managed_network_configs: Vec<ManagedNetworkConfig>,
pub config_revision: String,
}
@@ -187,17 +184,3 @@ impl WebhookConfig {
}
pub type SharedWebhookConfig = Arc<WebhookConfig>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_token_response_allows_missing_managed_configs() {
let resp: ValidateTokenResponse =
serde_json::from_str(r#"{"valid":true,"config_revision":"rev-1"}"#).unwrap();
assert!(resp.valid);
assert_eq!(resp.config_revision, "rev-1");
assert!(resp.managed_network_configs.is_none());
}
}
+29 -27
View File
@@ -51,7 +51,10 @@ time = "0.3"
toml = "0.8.12"
chrono = { version = "0.4.37", features = ["serde"] }
guarden = "0.2"
getset = "0.1.6"
optionize = "0.2"
guarden = "0.1"
delegate = "0.13.5"
@@ -59,7 +62,7 @@ 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"] }
@@ -70,10 +73,12 @@ 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"
moka = { version = "0.12", features = ["future"] }
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"
@@ -82,8 +87,7 @@ pin-project-lite = "0.2.13"
atomic_refcell = "0.1.13"
quinn = { version = "0.11.8", optional = true, features = ["ring"] }
quinn-proto = { version = "0.11.12", optional = true }
seahash = { version = "4.1.0", optional = true }
quinn-plaintext = { version = "0.3.0", optional = true }
rustls = { version = "0.23.0", features = [
"ring", "tls12"
@@ -91,7 +95,7 @@ rustls = { version = "0.23.0", features = [
rcgen = { version = "0.12.1", optional = true }
# for websocket
tokio-websockets = { version = "0.13.2", git = "https://github.com/EasyTier/tokio-websockets", optional = true, features = [
tokio-websockets = { version = "0.13.2", optional = true, features = [
"rustls-webpki-roots",
"client",
"server",
@@ -128,13 +132,10 @@ uuid = { version = "1.5.0", features = [
once_cell = "1.18.0"
# for rpc
prost = "0.14.3"
prost-reflect = { version = "0.16.4", default-features = false, features = ["derive"] }
prost-wkt-types = "0.7.1"
pbjson = "0.9.0"
prost = "0.13.5"
prost-wkt = "0.6"
prost-wkt-types = "0.6"
anyhow = "1.0"
ariadne = "0.5"
url = { version = "2.5", features = ["serde"] }
percent-encoding = "2.3.1"
@@ -156,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",
@@ -174,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 }
@@ -228,7 +231,11 @@ service-manager = { git = "https://github.com/EasyTier/service-manager-rs.git",
zstd = { version = "0.13", optional = true }
kcp-sys = { git = "https://github.com/EasyTier/kcp-sys", rev = "d7427c22d764deb1860a7d37acc446ed5033464c", optional = true }
kcp-sys = { git = "https://github.com/EasyTier/kcp-sys", rev = "94964794caaed5d388463137da59b97499619e5f", optional = true }
prost-reflect = { version = "0.14.5", default-features = false, features = [
"derive",
] }
# for http connector
http_req = { git = "https://github.com/EasyTier/http_req.git", default-features = false, features = [
@@ -236,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"
@@ -320,9 +324,9 @@ cfg_aliases = "0.2.1"
indoc = "2.0"
globwalk = "0.8.1"
regex = "1"
prost-build = "0.14.3"
prost-reflect-build = "0.16.0"
pbjson-build = "0.9.0"
prost-build = "0.13.5"
prost-wkt-build = "0.6"
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 = [
@@ -338,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"
@@ -375,7 +378,7 @@ full = [
"zstd",
]
wireguard = ["dep:boringtun", "dep:ring"]
quic = ["dep:quinn", "dep:quinn-proto", "dep:seahash", "dep:rustls", "dep:rcgen"]
quic = ["dep:quinn", "dep:quinn-plaintext", "dep:rustls", "dep:rcgen"]
kcp = ["dep:kcp-sys"]
mimalloc = ["dep:mimalloc"]
aes-gcm = ["dep:aes-gcm"]
@@ -391,7 +394,6 @@ websocket = [
]
smoltcp = ["dep:smoltcp"]
socks5 = ["smoltcp"]
ffi-dataplane = ["socks5"]
jemalloc = ["dep:jemallocator", "dep:jemalloc-sys"]
jemalloc-prof = [
"jemalloc",
@@ -401,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
+36 -16
View File
@@ -2,6 +2,7 @@ mod rpc;
use crate::rpc::ServiceGenerator;
use cfg_aliases::cfg_aliases;
use prost_wkt_build::{FileDescriptorSet, Message as _};
#[cfg(target_os = "windows")]
use std::io::Cursor;
use std::{env, path::PathBuf};
@@ -165,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",
];
@@ -173,31 +174,50 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("cargo:rerun-if-changed={proto_file}");
}
let out = PathBuf::from(env::var("OUT_DIR")?);
let descriptor = out.join("descriptors.bin");
let mut config = prost_build::Config::new();
config
.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)
.service_generator(Box::new(ServiceGenerator::default()))
.btree_map(["."])
.skip_debug([".common.Ipv4Addr", ".common.Ipv6Addr", ".common.UUID"]);
.extern_path(".google.protobuf.Value", "::prost_wkt_types::Value");
config.compile_protos(&proto_files, &["src/proto/"])?;
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)]")
.type_attribute(
"peer_rpc.RouteForeignNetworkSummary.Info",
"#[derive(Hash, Eq)]",
)
.type_attribute("peer_rpc.RouteForeignNetworkSummary", "#[derive(Hash, Eq)]")
.type_attribute("common.RpcDescriptor", "#[derive(Hash, Eq)]")
.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.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 = std::fs::read(descriptor)?;
pbjson_build::Builder::new()
.register_descriptors(&descriptor)?
.preserve_proto_field_names()
.btree_map(["."])
.build(&["."])?;
let descriptor_bytes = std::fs::read(descriptor_file)?;
let descriptor = FileDescriptorSet::decode(&descriptor_bytes[..])?;
prost_wkt_build::add_serde(out, descriptor);
check_locale();
Ok(())
-9
View File
@@ -205,9 +205,6 @@ core_clap:
bind_device:
en: "bind the connector socket to physical devices to avoid routing issues. e.g.: subnet proxy segment conflicts with a node's segment, after binding the physical device, it can communicate with the node normally."
zh-CN: "将连接器的套接字绑定到物理设备以避免路由问题。比如子网代理网段与某节点的网段冲突,绑定物理设备后可以与该节点正常通信。"
socket_mark:
en: "Linux only: set SO_MARK (fwmark) on EasyTier's underlay sockets (TCP, UDP, QUIC, WebSocket, WireGuard, and the FakeTCP decoy socket) so the host can policy-route or filter them with 'ip rule fwmark ...', nftables ('meta mark'), or iptables ('-m mark'). Any value is applied verbatim (0 is a valid mark); omit the flag to leave SO_MARK untouched. Requires CAP_NET_ADMIN. Note: FakeTCP payload travels via raw TUN writes which the kernel does not tag — mark those separately on the TUN device if needed."
zh-CN: "仅 Linux: 在 EasyTier 的底层套接字 (TCP、UDP、QUIC、WebSocket、WireGuard 以及 FakeTCP 诱饵套接字) 上设置 SO_MARK (fwmark),使主机能用 'ip rule fwmark ...'、nftables ('meta mark') 或 iptables ('-m mark') 策略路由/过滤这些数据包。任何值都会原样应用 (0 也是合法的 mark);不传该参数即保持 SO_MARK 不变。需要 CAP_NET_ADMIN 权限。注意:FakeTCP 的实际载荷通过原始 TUN 写入,内核不会为其打标记;如有需要请在 TUN 设备上单独打标记。"
enable_kcp_proxy:
en: "proxy tcp streams with kcp, improving the latency and throughput on the network with udp packet loss."
zh-CN: "使用 KCP 代理 TCP 流,提高在 UDP 丢包网络上的延迟和吞吐量。"
@@ -223,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 不同或缺失时会被拒绝。"
+261 -328
View File
@@ -1,37 +1,114 @@
use std::{
hash::Hasher,
net::{IpAddr, SocketAddr},
path::PathBuf,
sync::{Arc, Mutex},
};
use anyhow::Context;
use ariadne::{CharSet, Config as AriadneConfig, IndexType, Label, Report, ReportKind, Source};
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 super::env_parser;
use crate::utils::dns;
use crate::{
common::stun::StunInfoCollector,
instance::dns_server::DEFAULT_ET_DNS_ZONE,
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 strum::{Display, EnumString, VariantArray};
use tokio::io::AsyncReadExt as _;
use super::env_parser;
#[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,
}
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,
@@ -56,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,
@@ -66,15 +142,11 @@ 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,
socket_mark: None,
}
}
@@ -154,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);
@@ -242,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>);
@@ -279,20 +370,20 @@ pub struct NetworkIdentity {
pub enum ConfigSource {
#[default]
User,
Web,
Webhook,
}
impl ConfigSource {
pub fn as_str(self) -> &'static str {
match self {
Self::User => "user",
Self::Web => "web",
Self::Webhook => "webhook",
}
}
pub fn from_rpc(source: i32) -> Option<Self> {
match RpcConfigSource::try_from(source).ok() {
Some(RpcConfigSource::Web) => Some(Self::Web),
Some(RpcConfigSource::Webhook) => Some(Self::Webhook),
Some(RpcConfigSource::User) => Some(Self::User),
_ => None,
}
@@ -301,7 +392,7 @@ impl ConfigSource {
pub fn to_rpc(self) -> i32 {
match self {
Self::User => RpcConfigSource::User as i32,
Self::Web => RpcConfigSource::Web as i32,
Self::Webhook => RpcConfigSource::Webhook as i32,
}
}
}
@@ -312,7 +403,7 @@ impl std::str::FromStr for ConfigSource {
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"user" => Ok(Self::User),
"web" => Ok(Self::Web),
"webhook" => Ok(Self::Webhook),
other => Err(format!("unknown network config source: {other}")),
}
}
@@ -429,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>,
}
@@ -544,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>>,
@@ -565,40 +660,12 @@ 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>,
}
fn format_toml_parse_error(source_name: &str, config_str: &str, error: &toml::de::Error) -> String {
let message = format!("failed to parse config TOML from {source_name}");
let Some(span) = error.span() else {
return format!("{message}\ndetail: {error}");
};
let mut output = Vec::new();
let report = Report::build(ReportKind::Error, (source_name, span.clone()))
.with_config(
AriadneConfig::default()
.with_color(false)
.with_char_set(CharSet::Ascii)
.with_index_type(IndexType::Byte),
)
.with_message(&message)
.with_label(Label::new((source_name, span)).with_message(error.message()))
.finish();
if report
.write((source_name, Source::from(config_str)), &mut output)
.is_ok()
{
String::from_utf8_lossy(&output).into_owned()
} else {
format!("{message}\ndetail: {error}")
}
}
#[derive(Debug, Clone)]
pub struct TomlConfigLoader {
config: Arc<Mutex<Config>>,
@@ -621,79 +688,69 @@ impl TomlConfigLoader {
}
pub fn new_from_str(config_str: &str) -> Result<Self, anyhow::Error> {
Self::new_from_str_with_source("inline config", config_str)
}
pub fn new(config_path: &PathBuf) -> Result<Self, anyhow::Error> {
let config_str = std::fs::read_to_string(config_path)
.with_context(|| format!("failed to read config file: {}", config_path.display()))?;
let source_name = config_path.display().to_string();
Self::new_from_str_with_source(&source_name, &config_str)
}
pub(crate) fn new_from_str_with_source(
source_name: &str,
config_str: &str,
) -> Result<Self, anyhow::Error> {
let mut config = toml::de::from_str::<Config>(config_str).map_err(|err| {
let message = format_toml_parse_error(source_name, config_str, &err);
anyhow::Error::new(err).context(message)
})?;
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);
Self::new_from_config(config).map_err(|err| {
let message = format!("failed to load config from {source_name}: {err}");
err.context(message)
})
}
fn new_from_config(mut config: Config) -> Result<Self, anyhow::Error> {
config.flags_struct = Some(
Self::gen_flags(config.flags.clone().unwrap_or_default())
.context("failed to parse flags")?,
);
let has_network_identity = config.network_identity.is_some();
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)),
};
let old_ns = config.get_network_identity();
// Detect credential mode: secure_mode enabled + no network_secret in TOML
let is_credential = has_network_identity
&& config
.get_secure_mode()
.map(|sm| sm.enabled)
.unwrap_or(false)
&& old_ns
.network_secret
.as_deref()
.is_none_or(|s| s.is_empty());
if is_credential {
config.set_network_identity(NetworkIdentity::new_credential(old_ns.network_name));
} else {
config.set_network_identity(NetworkIdentity::new(
old_ns.network_name,
old_ns.network_secret.unwrap_or_default(),
));
}
config.set_network_identity(NetworkIdentity::new(
old_ns.network_name,
old_ns.network_secret.unwrap_or_default(),
));
Ok(config)
}
fn gen_flags(
flags_hashmap: serde_json::Map<String, serde_json::Value>,
) -> serde_json::Result<Flags> {
let mut merged_hashmap = match serde_json::to_value(gen_default_flags()) {
Ok(serde_json::Value::Object(map)) => map,
_ => serde_json::Map::new(),
};
merged_hashmap.extend(flags_hashmap);
serde_json::from_value(serde_json::Value::Object(merged_hashmap))
pub fn new(config_path: &PathBuf) -> Result<Self, anyhow::Error> {
let config_str = std::fs::read_to_string(config_path)
.with_context(|| format!("failed to read config file: {:?}", config_path))?;
let ret = Self::new_from_str(&config_str)?;
Ok(ret)
}
fn gen_flags(mut flags_hashmap: serde_json::Map<String, serde_json::Value>) -> Flags {
let default_flags_json = serde_json::to_string(&gen_default_flags()).unwrap();
let default_flags_hashmap =
serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&default_flags_json)
.unwrap();
let mut merged_hashmap = serde_json::Map::new();
for (key, value) in default_flags_hashmap {
if let Some(v) = flags_hashmap.remove(&key) {
merged_hashmap.insert(key, v);
} else {
merged_hashmap.insert(key, value);
}
}
serde_json::from_value(serde_json::Value::Object(merged_hashmap)).unwrap()
}
}
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;
}
}
_ => {}
}
}
@@ -712,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>) {
@@ -1059,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()
}
@@ -1121,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()
}
}
@@ -1250,13 +1318,13 @@ pub async fn load_config_from_file(
.read_to_string(&mut stdin)
.await
.context("failed to read config from stdin")?;
let config = TomlConfigLoader::new_from_str_with_source("stdin", &stdin)?;
let config = TomlConfigLoader::new_from_str(&stdin)?;
return Ok((config, ConfigFileControl::STATIC_CONFIG));
}
let config_str = tokio::fs::read_to_string(config_file)
.await
.with_context(|| format!("failed to read config file: {}", config_file.display()))?;
.with_context(|| format!("failed to read config file: {:?}", config_file))?;
let (expanded_config_str, uses_env_vars) = if disable_env_parsing {
(config_str.clone(), false)
@@ -1278,8 +1346,8 @@ pub async fn load_config_from_file(
);
}
let source_name = config_file.display().to_string();
let config = TomlConfigLoader::new_from_str_with_source(&source_name, &expanded_config_str)?;
let config = TomlConfigLoader::new_from_str(&expanded_config_str)
.with_context(|| format!("failed to load config file: {:?}", config_file))?;
let mut control = ConfigFileControl::from_path(config_file.clone()).await;
@@ -1319,147 +1387,6 @@ pub mod tests {
use std::path::PathBuf;
use tempfile::NamedTempFile;
#[test]
fn invalid_toml_error_includes_location_and_source_line() {
let error = TomlConfigLoader::new_from_str("dhcp = \"yes\"").unwrap_err();
let display = error.to_string();
assert!(display.contains("failed to parse config TOML"));
assert!(display.contains("inline config"));
assert!(display.contains("dhcp = \"yes\""));
assert!(display.contains("^"));
assert!(display.contains("invalid type: string"));
assert!(!display.contains("<unknown>"));
assert!(
error
.chain()
.any(|err| err.downcast_ref::<toml::de::Error>().is_some())
);
}
#[test]
fn invalid_file_toml_error_includes_config_source() {
let mut config_file = NamedTempFile::new().unwrap();
writeln!(config_file, "dhcp = \"yes\"").unwrap();
let error = TomlConfigLoader::new(&config_file.path().to_path_buf()).unwrap_err();
let error = error.to_string();
assert!(error.contains(config_file.path().to_string_lossy().as_ref()));
assert!(error.contains("failed to parse config TOML"));
assert!(error.contains("dhcp = \"yes\""));
assert!(error.contains("^"));
assert!(error.contains("invalid type: string"));
assert!(!error.contains("<unknown>"));
}
#[test]
fn invalid_stdin_toml_error_includes_config_source_in_display() {
let error = TomlConfigLoader::new_from_str_with_source("stdin", "dhcp = \"yes\"")
.unwrap_err()
.to_string();
assert!(error.contains("stdin"));
assert!(error.contains("failed to parse config TOML"));
assert!(error.contains("dhcp = \"yes\""));
assert!(error.contains("^"));
assert!(error.contains("invalid type: string"));
assert!(!error.contains("<unknown>"));
}
#[test]
fn invalid_toml_error_handles_non_ascii_before_error() {
let error = TomlConfigLoader::new_from_str("hostname = \"节点\"\ndhcp = \"yes\"")
.unwrap_err()
.to_string();
assert!(error.contains("dhcp = \"yes\""));
assert!(error.contains("^"));
assert!(error.contains("invalid type: string"));
}
#[test]
fn invalid_toml_error_handles_non_ascii_before_error_on_same_line() {
let error = TomlConfigLoader::new_from_str("hostname = \"节点\" dhcp = \"yes\"")
.unwrap_err()
.to_string();
assert!(error.contains("failed to parse config TOML"));
assert!(error.contains("inline config:1:"));
assert!(error.contains("hostname = \"节点\" dhcp = \"yes\""));
assert!(error.contains("expected newline"));
assert!(!error.contains("<unknown>"));
}
#[test]
fn invalid_file_flags_error_includes_config_source_in_display() {
let mut config_file = NamedTempFile::new().unwrap();
writeln!(config_file, "[flags]").unwrap();
writeln!(config_file, "socket_mark = \"bad\"").unwrap();
let error = TomlConfigLoader::new(&config_file.path().to_path_buf()).unwrap_err();
let display = error.to_string();
assert!(display.contains(config_file.path().to_string_lossy().as_ref()));
assert!(display.contains("failed to load config"));
assert!(display.contains("failed to parse flags"));
// with_context preserves the cause chain so callers can inspect the root reason.
let chain: Vec<String> = error.chain().map(|e| e.to_string()).collect();
assert!(chain.iter().any(|m| m.contains("failed to parse flags")));
}
#[test]
fn socket_mark_config_file_roundtrip_none_some_and_zero() {
// Omitting the flag leaves socket_mark unset (None) -> SO_MARK untouched.
let cfg = TomlConfigLoader::new_from_str(
r#"
[network_identity]
network_name = "n"
network_secret = "s"
"#,
)
.unwrap();
assert_eq!(cfg.get_flags().socket_mark, None);
// socket_mark = 0 is a legitimate value distinct from "unset".
let cfg = TomlConfigLoader::new_from_str(
r#"
[network_identity]
network_name = "n"
network_secret = "s"
[flags]
socket_mark = 0
"#,
)
.unwrap();
assert_eq!(cfg.get_flags().socket_mark, Some(0));
// A non-zero mark round-trips as Some(v).
let cfg = TomlConfigLoader::new_from_str(
r#"
[network_identity]
network_name = "n"
network_secret = "s"
[flags]
socket_mark = 66
"#,
)
.unwrap();
assert_eq!(cfg.get_flags().socket_mark, Some(66));
// set_flags(None) must serialize back through gen_config without
// resurrecting a value (guards the gen_flags merge against dropping
// the key when the serialized default is null).
cfg.set_flags(Flags {
socket_mark: None,
..cfg.get_flags()
});
assert_eq!(cfg.get_flags().socket_mark, None);
}
#[test]
fn test_stun_servers_config() {
let config = TomlConfigLoader::default();
@@ -1493,58 +1420,64 @@ 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::Web));
config.set_network_config_source(Some(ConfigSource::Webhook));
let dumped = config.dump();
assert!(dumped.contains("[source]"));
assert!(dumped.contains("source = \"web\""));
assert!(dumped.contains("source = \"webhook\""));
let loaded = TomlConfigLoader::new_from_str(&dumped).unwrap();
assert_eq!(loaded.get_network_config_source(), ConfigSource::Web);
}
#[test]
fn test_toml_credential_mode_omits_network_secret() {
for network_secret in ["", r#"network_secret = """#] {
let config = TomlConfigLoader::new_from_str(&format!(
r#"
[network_identity]
network_name = "credential-network"
{network_secret}
[secure_mode]
enabled = true
"#
))
.unwrap();
let identity = config.get_network_identity();
assert_eq!(identity.network_name, "credential-network");
assert_eq!(identity.network_secret, None);
assert_eq!(identity.network_secret_digest, None);
assert!(!config.dump().contains("network_secret"));
}
}
#[test]
fn test_toml_secure_mode_without_network_identity_uses_default_secret() {
let config = TomlConfigLoader::new_from_str(
r#"
[secure_mode]
enabled = true
"#,
)
.unwrap();
let identity = config.get_network_identity();
assert_eq!(identity.network_name, "default");
assert_eq!(identity.network_secret.as_deref(), Some(""));
assert!(identity.network_secret_digest.is_some());
assert_eq!(loaded.get_network_config_source(), ConfigSource::Webhook);
}
#[test]
-151
View File
@@ -1,151 +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()))?;
// 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 super::*;
use guarden::defer;
#[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);
}
#[tokio::test]
async fn socket_addrs_preserves_explicit_zero_port() {
let cases = [
("ws://127.0.0.1:0", 80, 0),
("wss://127.0.0.1:0", 443, 0),
("ws://127.0.0.1", 80, 80),
("wss://127.0.0.1", 443, 443),
];
for (raw_url, default_port, expected_port) in cases {
let url = url::Url::parse(raw_url).unwrap();
let addrs = socket_addrs(&url, || Some(default_port)).await.unwrap();
assert_eq!(
addrs,
vec![SocketAddr::from(([127, 0, 0, 1], expected_port))]
);
}
}
}
+55 -9
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::{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,6 +53,8 @@ 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)
@@ -255,7 +262,7 @@ impl std::fmt::Debug for GlobalCtx {
}
}
pub type ArcGlobalCtx = std::sync::Arc<GlobalCtx>;
pub type ArcGlobalCtx = Arc<GlobalCtx>;
impl GlobalCtx {
fn apply_disable_relay_data_flag(
@@ -287,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() {
@@ -491,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) {
@@ -786,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::{
+2 -4
View File
@@ -6,6 +6,7 @@ use std::{
time::{Duration, Instant},
};
use crate::utils::hostname;
use anyhow::Context as _;
#[cfg(unix)]
use nix::{
@@ -233,10 +234,7 @@ fn machine_uid_seed() -> Option<String> {
fn linux_machine_id_seed(machine_uid: &str) -> String {
let mut seed = format!("machine_uid={machine_uid}");
let hostname = gethostname::gethostname()
.to_string_lossy()
.trim()
.to_string();
let hostname = hostname();
if !hostname.is_empty() {
seed.push_str("\nhostname=");
seed.push_str(&hostname);
-1
View File
@@ -11,7 +11,6 @@ pub mod acl_processor;
pub mod compressor;
pub mod config;
pub mod constants;
pub mod dns;
pub mod env_parser;
pub mod error;
pub mod global_ctx;
+103 -75
View File
@@ -1,9 +1,8 @@
use dashmap::DashMap;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::cell::UnsafeCell;
use std::fmt;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use tokio::time::interval;
use tokio_util::task::AbortOnDropHandle;
@@ -23,8 +22,6 @@ pub enum MetricName {
PeerRpcDuration,
/// RPC errors
PeerRpcErrors,
/// RPC/control packets dropped because the peer RPC queue is unavailable
PeerRpcPacketQueueDrops,
/// Data-plane traffic bytes sent
TrafficBytesTx,
@@ -118,7 +115,6 @@ impl fmt::Display for MetricName {
MetricName::PeerRpcServerRx => write!(f, "peer_rpc_server_rx"),
MetricName::PeerRpcDuration => write!(f, "peer_rpc_duration_ms"),
MetricName::PeerRpcErrors => write!(f, "peer_rpc_errors"),
MetricName::PeerRpcPacketQueueDrops => write!(f, "peer_rpc_packet_queue_drops"),
MetricName::TrafficBytesTx => write!(f, "traffic_bytes_tx"),
MetricName::TrafficBytesTxByInstance => write!(f, "traffic_bytes_tx_by_instance"),
@@ -378,10 +374,10 @@ impl Default for LabelSet {
}
}
/// UnsafeCounter provides a high-performance atomic counter
/// UnsafeCounter provides a high-performance counter using UnsafeCell
#[derive(Debug)]
pub struct UnsafeCounter {
value: AtomicU64,
value: UnsafeCell<u64>,
}
impl Default for UnsafeCounter {
@@ -393,79 +389,121 @@ impl Default for UnsafeCounter {
impl UnsafeCounter {
pub fn new() -> Self {
Self {
value: AtomicU64::new(0),
value: UnsafeCell::new(0),
}
}
pub fn new_with_value(initial: u64) -> Self {
Self {
value: AtomicU64::new(initial),
value: UnsafeCell::new(initial),
}
}
/// Increment the counter by the given amount
pub fn add(&self, delta: u64) {
let _ = self
.value
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
Some(current.saturating_add(delta))
});
/// # Safety
/// This method is unsafe because it uses UnsafeCell. The caller must ensure
/// that no other thread is accessing this counter simultaneously.
pub unsafe fn add(&self, delta: u64) {
let ptr = self.value.get();
unsafe {
*ptr = (*ptr).saturating_add(delta);
}
}
/// Increment the counter by 1
pub fn inc(&self) {
self.add(1);
/// # Safety
/// This method is unsafe because it uses UnsafeCell. The caller must ensure
/// that no other thread is accessing this counter simultaneously.
pub unsafe fn inc(&self) {
unsafe {
self.add(1);
}
}
/// Get the current value of the counter
pub fn get(&self) -> u64 {
self.value.load(Ordering::Relaxed)
/// # Safety
/// This method is unsafe because it uses UnsafeCell. The caller must ensure
/// that no other thread is modifying this counter simultaneously.
pub unsafe fn get(&self) -> u64 {
let ptr = self.value.get();
unsafe { *ptr }
}
/// Reset the counter to zero
pub fn reset(&self) {
self.value.store(0, Ordering::Relaxed);
/// # Safety
/// This method is unsafe because it uses UnsafeCell. The caller must ensure
/// that no other thread is accessing this counter simultaneously.
pub unsafe fn reset(&self) {
let ptr = self.value.get();
unsafe {
*ptr = 0;
}
}
/// Set the counter to a specific value
pub fn set(&self, value: u64) {
self.value.store(value, Ordering::Relaxed);
/// # Safety
/// This method is unsafe because it uses UnsafeCell. The caller must ensure
/// that no other thread is accessing this counter simultaneously.
pub unsafe fn set(&self, value: u64) {
let ptr = self.value.get();
unsafe {
*ptr = value;
}
}
}
// UnsafeCounter is Send + Sync because the safety is guaranteed by the caller
unsafe impl Send for UnsafeCounter {}
unsafe impl Sync for UnsafeCounter {}
/// MetricData contains both the counter and last update timestamp
/// Uses UnsafeCell for lock-free access
#[derive(Debug)]
struct MetricData {
counter: UnsafeCounter,
last_updated: Mutex<Instant>,
last_updated: UnsafeCell<Instant>,
}
impl MetricData {
fn new() -> Self {
Self {
counter: UnsafeCounter::new(),
last_updated: Mutex::new(Instant::now()),
last_updated: UnsafeCell::new(Instant::now()),
}
}
fn new_with_value(initial: u64) -> Self {
Self {
counter: UnsafeCounter::new_with_value(initial),
last_updated: Mutex::new(Instant::now()),
last_updated: UnsafeCell::new(Instant::now()),
}
}
/// Update the last_updated timestamp
fn touch(&self) {
*self.last_updated.lock() = Instant::now();
/// # Safety
/// This method is unsafe because it uses UnsafeCell. The caller must ensure
/// that no other thread is accessing this timestamp simultaneously.
unsafe fn touch(&self) {
let ptr = self.last_updated.get();
unsafe {
*ptr = Instant::now();
}
}
/// Get the last updated timestamp
fn get_last_updated(&self) -> Instant {
*self.last_updated.lock()
/// # Safety
/// This method is unsafe because it uses UnsafeCell. The caller must ensure
/// that no other thread is modifying this timestamp simultaneously.
unsafe fn get_last_updated(&self) -> Instant {
let ptr = self.last_updated.get();
unsafe { *ptr }
}
}
// MetricData is Send + Sync because the safety is guaranteed by the caller
unsafe impl Send for MetricData {}
unsafe impl Sync for MetricData {}
/// MetricKey uniquely identifies a metric with its name and labels
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct MetricKey {
@@ -508,31 +546,39 @@ impl CounterHandle {
/// Increment the counter by the given amount
pub fn add(&self, delta: u64) {
self.metric_data.counter.add(delta);
self.metric_data.touch();
unsafe {
self.metric_data.counter.add(delta);
self.metric_data.touch();
}
}
/// Increment the counter by 1
pub fn inc(&self) {
self.metric_data.counter.inc();
self.metric_data.touch();
unsafe {
self.metric_data.counter.inc();
self.metric_data.touch();
}
}
/// Get the current value of the counter
pub fn get(&self) -> u64 {
self.metric_data.counter.get()
unsafe { self.metric_data.counter.get() }
}
/// Reset the counter to zero
pub fn reset(&self) {
self.metric_data.counter.reset();
self.metric_data.touch();
unsafe {
self.metric_data.counter.reset();
self.metric_data.touch();
}
}
/// Set the counter to a specific value
pub fn set(&self, value: u64) {
self.metric_data.counter.set(value);
self.metric_data.touch();
unsafe {
self.metric_data.counter.set(value);
self.metric_data.touch();
}
}
}
@@ -578,7 +624,7 @@ impl StatsManager {
counters.retain(|_, metric_data: &mut Arc<MetricData>| {
Arc::strong_count(metric_data) > 1
|| metric_data.get_last_updated() > cutoff_time
|| unsafe { metric_data.get_last_updated() > cutoff_time }
});
counters.shrink_to_fit();
}
@@ -616,7 +662,7 @@ impl StatsManager {
let key = entry.key();
let metric_data = entry.value();
let value = metric_data.counter.get();
let value = unsafe { metric_data.counter.get() };
metrics.push(MetricSnapshot {
name: key.name,
@@ -649,7 +695,7 @@ impl StatsManager {
let key = MetricKey::new(name, labels.clone());
if let Some(metric_data) = self.counters.get(&key) {
let value = metric_data.counter.get();
let value = unsafe { metric_data.counter.get() };
Some(MetricSnapshot {
name,
labels: labels.clone(),
@@ -750,15 +796,17 @@ mod tests {
async fn test_unsafe_counter() {
let counter = UnsafeCounter::new();
assert_eq!(counter.get(), 0);
counter.inc();
assert_eq!(counter.get(), 1);
counter.add(5);
assert_eq!(counter.get(), 6);
counter.set(10);
assert_eq!(counter.get(), 10);
counter.reset();
assert_eq!(counter.get(), 0);
unsafe {
assert_eq!(counter.get(), 0);
counter.inc();
assert_eq!(counter.get(), 1);
counter.add(5);
assert_eq!(counter.get(), 6);
counter.set(10);
assert_eq!(counter.get(), 10);
counter.reset();
assert_eq!(counter.get(), 0);
}
}
#[tokio::test]
@@ -903,7 +951,8 @@ mod tests {
stats
.counters
.retain(|_, metric_data: &mut Arc<MetricData>| {
Arc::strong_count(metric_data) > 1 || metric_data.get_last_updated() > cutoff_time
Arc::strong_count(metric_data) > 1
|| unsafe { metric_data.get_last_updated() > cutoff_time }
});
assert_eq!(stats.metric_count(), 1);
@@ -913,33 +962,12 @@ mod tests {
stats
.counters
.retain(|_, metric_data: &mut Arc<MetricData>| {
Arc::strong_count(metric_data) > 1 || metric_data.get_last_updated() > cutoff_time
Arc::strong_count(metric_data) > 1
|| unsafe { metric_data.get_last_updated() > cutoff_time }
});
assert_eq!(stats.metric_count(), 0);
}
#[tokio::test]
async fn test_counter_handle_concurrent_increment() {
const THREADS: usize = 8;
const INCREMENTS_PER_THREAD: usize = 10_000;
let stats = StatsManager::new();
let counter = stats.get_simple_counter(MetricName::TrafficPacketsForwarded);
std::thread::scope(|scope| {
for _ in 0..THREADS {
let counter = counter.clone();
scope.spawn(move || {
for _ in 0..INCREMENTS_PER_THREAD {
counter.inc();
}
});
}
});
assert_eq!(counter.get(), (THREADS * INCREMENTS_PER_THREAD) as u64);
}
#[tokio::test]
async fn test_stats_rpc_data_structures() {
// Test GetStatsRequest
+79 -27
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))
@@ -1349,6 +1381,26 @@ mod tests {
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(
@@ -1563,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));
}
}
+2 -4
View File
@@ -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,
@@ -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;
+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 {
+3 -3
View File
@@ -9,7 +9,7 @@ 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::{
@@ -22,6 +22,8 @@ use crate::{
utils::weak_upgrade,
};
use super::create_connector_by_url;
use crate::utils::dns::socket_addrs;
use crate::{
common::{
error::Error,
@@ -32,8 +34,6 @@ use crate::{
use_global_var,
};
use super::create_connector_by_url;
type ConnectorMap = Arc<DashSet<url::Url>>;
#[derive(Debug, Clone)]
+2 -3
View File
@@ -1,14 +1,14 @@
use std::net::{IpAddr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6};
use crate::{
common::{dns::socket_addrs, error::Error, global_ctx::ArcGlobalCtx, idn},
common::{error::Error, global_ctx::ArcGlobalCtx, idn},
connector::dns_connector::DnsTunnelConnector,
proto::common::PeerFeatureFlag,
tunnel::{
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;
@@ -268,7 +268,6 @@ pub async fn create_connector_by_url(
IpScheme::FakeTcp => tunnel::fake_tcp::FakeTcpTunnelConnector::new(url).boxed(),
};
connector.set_resolved_addr(resolved_addr.addr);
connector.set_socket_mark(global_ctx.config.get_flags().socket_mark);
if global_ctx.config.get_flags().bind_device {
set_bind_addr_for_peer_connector(
&mut connector,
+13 -79
View File
@@ -533,17 +533,6 @@ struct NetworkOptions {
)]
bind_device: Option<bool>,
// SO_MARK (fwmark) is a Linux-family kernel feature. Gate the flag out
// entirely on other targets so users on Windows/macOS/BSD don't see a
// `--socket-mark` they can't act on.
#[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))]
#[arg(
long,
env = "ET_SOCKET_MARK",
help = t!("core_clap.socket_mark").to_string()
)]
socket_mark: Option<u32>,
#[arg(
long,
env = "ET_ENABLE_KCP_PROXY",
@@ -589,19 +578,6 @@ struct NetworkOptions {
)]
port_forward: Vec<url::Url>,
#[arg(
long,
env = "ET_ACCEPT_DNS",
help = t!("core_clap.accept_dns").to_string(),
)]
accept_dns: Option<bool>,
#[arg(
long = "tld-dns-zone",
env = "ET_TLD_DNS_ZONE",
help = t!("core_clap.tld_dns_zone").to_string())]
tld_dns_zone: Option<String>,
#[arg(
long,
env = "ET_PRIVATE_MODE",
@@ -891,20 +867,17 @@ impl NetworkOptions {
}
let old_ns = cfg.get_network_identity();
let network_name = self
.network_name
.clone()
.unwrap_or_else(|| old_ns.network_name.clone());
let network_name = self.network_name.clone().unwrap_or(old_ns.network_name);
if self.credential.is_some() {
// Credential mode: no network_secret, authenticate via credential keypair
cfg.set_network_identity(NetworkIdentity::new_credential(network_name));
} else if let Some(network_secret) = &self.network_secret {
cfg.set_network_identity(NetworkIdentity::new(network_name, network_secret.clone()));
} else if let Some(network_secret) = old_ns.network_secret {
cfg.set_network_identity(NetworkIdentity::new(network_name, network_secret));
} else {
cfg.set_network_identity(NetworkIdentity::new_credential(network_name));
let network_secret = self
.network_secret
.clone()
.unwrap_or(old_ns.network_secret.unwrap_or_default());
cfg.set_network_identity(NetworkIdentity::new(network_name, network_secret));
}
if let Some(dhcp) = self.dhcp {
@@ -1140,15 +1113,10 @@ impl NetworkOptions {
.into();
}
f.bind_device = self.bind_device.unwrap_or(f.bind_device);
#[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))]
{
f.socket_mark = self.socket_mark.or(f.socket_mark);
}
f.enable_kcp_proxy = self.enable_kcp_proxy.unwrap_or(f.enable_kcp_proxy);
f.disable_kcp_input = self.disable_kcp_input.unwrap_or(f.disable_kcp_input);
f.enable_quic_proxy = self.enable_quic_proxy.unwrap_or(f.enable_quic_proxy);
f.disable_quic_input = self.disable_quic_input.unwrap_or(f.disable_quic_input);
f.accept_dns = self.accept_dns.unwrap_or(f.accept_dns);
f.private_mode = self.private_mode.unwrap_or(f.private_mode);
f.foreign_relay_bps_limit = self
.foreign_relay_bps_limit
@@ -1172,10 +1140,6 @@ impl NetworkOptions {
f.enable_udp_broadcast_relay = self
.enable_udp_broadcast_relay
.unwrap_or(f.enable_udp_broadcast_relay);
// Configure tld_dns_zone: use provided value if set
if let Some(tld_dns_zone) = &self.tld_dns_zone {
f.tld_dns_zone = tld_dns_zone.clone();
}
cfg.set_flags(f);
if !self.exit_nodes.is_empty() {
@@ -1614,7 +1578,7 @@ pub async fn main() -> ExitCode {
// Verify configurations
if cli.check_config {
if let Err(error) = validate_config(&cli).await {
log::error!(%error, "Config validation failed");
log::error!(?error, "Config validation failed");
return ExitCode::FAILURE;
} else {
return ExitCode::SUCCESS;
@@ -1624,7 +1588,7 @@ pub async fn main() -> ExitCode {
let mut ret_code = 0;
if let Err(error) = run_main(cli).await {
log::error!(%error);
log::error!(?error);
ret_code = 1;
}
@@ -1644,13 +1608,12 @@ async fn validate_config(cli: &Cli) -> anyhow::Result<()> {
for config_file in config_files {
if config_file == &PathBuf::from("-") {
let mut stdin = String::new();
_ = tokio::io::stdin()
.read_to_string(&mut stdin)
.await
.context("failed to read config from stdin")?;
TomlConfigLoader::new_from_str_with_source("stdin", stdin.as_str())?;
_ = tokio::io::stdin().read_to_string(&mut stdin).await?;
TomlConfigLoader::new_from_str(stdin.as_str())
.with_context(|| "config source: stdin")?;
} else {
TomlConfigLoader::new(config_file)?;
TomlConfigLoader::new(config_file)
.with_context(|| format!("config source: {:?}", config_file))?;
};
}
@@ -1743,33 +1706,4 @@ mod tests {
);
}
}
#[test]
fn test_network_options_merge_preserves_credential_identity() {
let cfg = TomlConfigLoader::new_from_str(
r#"
[network_identity]
network_name = "credential-network"
network_secret = ""
[secure_mode]
enabled = true
"#,
)
.unwrap();
assert_eq!(cfg.get_network_identity().network_secret, None);
NetworkOptions {
hostname: Some("override-host".to_string()),
..Default::default()
}
.merge_into(&cfg)
.unwrap();
let identity = cfg.get_network_identity();
assert_eq!(identity.network_name, "credential-network");
assert_eq!(identity.network_secret, None);
assert_eq!(identity.network_secret_digest, None);
assert_eq!(cfg.get_hostname(), "override-host");
}
}
+55
View File
@@ -0,0 +1,55 @@
use crate::common::config::ConfigBase;
use crate::dns::config::policy::DnsPolicyConfig;
use crate::dns::config::zone::ZoneConfig;
use crate::dns::config::{DNS_DEFAULT_ADDRESSES, DNS_DEFAULT_DOMAIN};
use crate::dns::utils::addr::NameServerAddrGroup;
use crate::proto::dns::GetExportConfigResponse;
use hickory_proto::rr::LowerName;
use optionize::{Optionizable, optionized};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[optionized]
#[optionize(name = "DnsConfigRaw")]
#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
pub struct DnsConfigParsed {
pub disabled: bool,
#[serde(rename = "zone")]
pub zones: Vec<ZoneConfig>,
#[optionize(flatten)]
#[serde(flatten)]
pub policies: HashMap<LowerName, DnsPolicyConfig>,
#[optionize(flatten)]
pub name: Option<LowerName>,
pub domain: LowerName,
pub addresses: NameServerAddrGroup,
pub listeners: NameServerAddrGroup,
}
pub type DnsConfig = ConfigBase<DnsConfigRaw, DnsConfigParsed, ()>;
impl From<DnsConfigRaw> for DnsConfig {
fn from(raw: DnsConfigRaw) -> Self {
let mut parsed = DnsConfigParsed {
domain: DNS_DEFAULT_DOMAIN.clone(),
addresses: DNS_DEFAULT_ADDRESSES.clone(),
..Default::default()
};
parsed.load(raw.clone());
Self::new(parsed, raw, ())
}
}
#[auto_impl::auto_impl(Box, &)]
pub trait DnsConfigLoaderExt {
fn get_dns(&self) -> DnsConfig;
fn set_dns(&self, dns: DnsConfig);
}
pub type DnsExportConfig = GetExportConfigResponse;
pub trait DnsGlobalCtxExt {
fn dns_self_zone(&self) -> ZoneConfig;
fn dns_export_config(&self) -> DnsExportConfig;
fn dns_iter_zones(&self) -> impl Iterator<Item = ZoneConfig>;
}
+29
View File
@@ -0,0 +1,29 @@
use crate::dns::utils::addr::NameServerAddrGroup;
use hickory_proto::rr::LowerName;
use std::net::IpAddr;
use std::str::FromStr;
use std::sync::LazyLock;
use std::time::Duration;
use url::Url;
mod dns;
pub use dns::*;
mod policy;
pub mod zone;
pub static DNS_DEFAULT_DOMAIN: LazyLock<LowerName> =
LazyLock::new(|| LowerName::from_str("et.net.").unwrap());
pub static DNS_DEFAULT_ADDRESSES: LazyLock<NameServerAddrGroup> =
LazyLock::new(|| IpAddr::from_str("100.100.100.101").unwrap().into());
pub static DNS_SERVER_RPC_ADDR: LazyLock<Url> =
LazyLock::new(|| Url::parse("tcp://127.0.0.1:49813").unwrap());
pub const DNS_NODE_TTI: Duration = Duration::from_secs(5);
pub const DNS_NODE_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(2);
pub const DNS_NODE_RECONCILE_INTERVAL: Duration = Duration::from_secs(10);
pub const DNS_SERVER_ELECTION_INTERVAL: Duration = Duration::from_secs(5);
pub const DNS_PEER_TTI: Duration = Duration::from_secs(3);
pub const DNS_PEER_REFRESH_ATTEMPTS: usize = 3;
pub const DNS_PEER_REFRESH_BACKOFF: Duration = Duration::from_secs(1);
+46
View File
@@ -0,0 +1,46 @@
use derive_more::{Deref, DerefMut};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct AclPolicy {
pub whitelist: Option<Vec<String>>,
pub blacklist: Option<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Default, Deserialize, Serialize, Deref, DerefMut)]
#[serde(default)]
pub struct FunctionalityPolicy {
#[serde(flatten)]
#[deref]
#[deref_mut]
acl: AclPolicy, // TODO
pub disabled: bool,
}
#[derive(Debug, Clone, PartialEq, Default, Deserialize, Serialize, Deref, DerefMut)]
#[serde(default)]
pub struct DnsPolicy<P = FunctionalityPolicy> {
#[serde(flatten)]
#[deref]
#[deref_mut]
policy: P,
pub recursive: bool, // TODO
}
pub type ZoneExportPolicy = FunctionalityPolicy;
pub type DnsExportPolicy = DnsPolicy<ZoneExportPolicy>;
pub type DnsImportPolicy = DnsPolicy<FunctionalityPolicy>;
#[derive(Debug, Clone, PartialEq, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct DnsPolicyConfig {
pub import: DnsImportPolicy,
pub export: Option<DnsExportPolicy>,
}
#[derive(Debug, Clone, PartialEq, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct ZonePolicyConfig {
pub export: Option<DnsExportPolicy>,
}
+111
View File
@@ -0,0 +1,111 @@
use crate::common::config::ConfigBase;
use crate::dns::config::policy::{DnsExportPolicy, ZonePolicyConfig};
use crate::dns::utils::addr::NameServerAddrGroup;
use crate::dns::zone::Zone;
use crate::proto::dns::ZoneData;
use derive_more::From;
use hickory_proto::op::ResponseCode;
use hickory_proto::rr::LowerName;
use maplit::hashset;
use optionize::{Optionizable, optionized};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::convert::TryFrom;
use std::net::{Ipv4Addr, Ipv6Addr};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Hash, From, Deserialize, Serialize)]
#[serde(untagged)]
pub enum Fallthrough {
Any,
ResponseCode(ResponseCode),
}
impl From<Fallthrough> for i32 {
fn from(value: Fallthrough) -> Self {
match value {
Fallthrough::ResponseCode(code) => u16::from(code).into(),
Fallthrough::Any => -1,
}
}
}
impl From<i32> for Fallthrough {
fn from(value: i32) -> Self {
match u16::try_from(value) {
Ok(value) => Self::ResponseCode(value.into()),
Err(_) => Self::Any,
}
}
}
#[optionized]
#[optionize(name = "ZoneConfigRaw")]
#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
pub struct ZoneConfigParsed {
#[optionize(flatten)]
pub origin: LowerName,
pub ttl: u32,
pub records: Vec<String>,
pub forwarders: NameServerAddrGroup,
#[optionize(flatten)]
#[serde(flatten)]
pub policy: ZonePolicyConfig,
pub fallthrough: HashSet<Fallthrough>,
}
impl From<&ZoneConfigParsed> for ZoneData {
fn from(value: &ZoneConfigParsed) -> Self {
Self::new(
&value.origin,
value.ttl,
&value.records,
value.forwarders.iter().map(Into::into),
value.fallthrough.iter().copied(),
)
}
}
pub type ZoneConfig = ConfigBase<ZoneConfigRaw, ZoneConfigParsed, ZoneData>;
impl TryFrom<ZoneConfigRaw> for ZoneConfig {
type Error = anyhow::Error;
fn try_from(raw: ZoneConfigRaw) -> Result<Self, Self::Error> {
let mut parsed = ZoneConfigParsed {
fallthrough: hashset! {Fallthrough::Any},
..Default::default()
};
parsed.load(raw.clone());
let data = (&parsed).into();
let _ = Zone::try_from(&data)?; // validation
Ok(Self::new(parsed, raw, data))
}
}
impl ZoneConfig {
pub fn dedicated(origin: LowerName, ipv4: Option<Ipv4Addr>, ipv6: Vec<Ipv6Addr>) -> Self {
let mut records = Vec::new();
if let Some(ipv4) = ipv4 {
records.push(format!("@ IN A {}", ipv4));
}
for ipv6 in ipv6 {
records.push(format!("@ IN AAAA {}", ipv6));
}
let policy = ZonePolicyConfig {
export: Some(DnsExportPolicy::default()),
};
let parsed = ZoneConfigParsed {
origin,
records,
policy,
..Default::default()
};
let data = (&parsed).into();
Self::new(parsed, Default::default(), data)
}
}
+11
View File
@@ -0,0 +1,11 @@
pub mod config;
pub mod node;
mod node_mgr;
mod peer_mgr;
pub mod server;
mod system;
mod utils;
mod zone;
#[cfg(test)]
mod tests;
+560
View File
@@ -0,0 +1,560 @@
use crate::common::global_ctx::{ArcGlobalCtx, GlobalCtxEvent};
use crate::dns::config::{
DNS_NODE_HEARTBEAT_INTERVAL, DNS_NODE_RECONCILE_INTERVAL, DNS_PEER_REFRESH_ATTEMPTS,
DNS_PEER_REFRESH_BACKOFF, DNS_SERVER_ELECTION_INTERVAL, DNS_SERVER_RPC_ADDR,
};
use crate::dns::peer_mgr::DnsPeerMgr;
use crate::dns::server::DnsServer;
#[cfg(feature = "tun")]
use crate::instance::instance::ArcNicCtx;
use crate::peers::peer_manager::PeerManager;
use crate::proto::dns::{DnsNodeMgrRpcClientFactory, HeartbeatRequest};
use crate::proto::rpc_impl::standalone::{StandAloneClient, StandAloneServer};
use crate::proto::rpc_types::controller::BaseController;
use crate::tunnel::tcp::{TcpTunnelConnector, TcpTunnelListener};
use crate::utils::task::CancellableTask;
use std::io;
use std::sync::Arc;
use tokio::sync::{Notify, broadcast};
use tokio::task::JoinSet;
use tokio::time::{MissedTickBehavior, interval};
use tokio_util::sync::CancellationToken;
use tracing::instrument;
use uuid::Uuid;
#[derive(Debug, Clone)]
struct DnsNodeRuntime {
mgr: DnsPeerMgr,
#[cfg(feature = "tun")]
nic_ctx: ArcNicCtx, // TODO: REMOVE THIS
peer_mgr: Arc<PeerManager>,
global_ctx: ArcGlobalCtx,
elect: Arc<Notify>,
}
impl DnsNodeRuntime {
fn id(&self) -> Uuid {
self.global_ctx.get_id()
}
#[instrument(skip_all, name = "DnsNode election loop")]
async fn run_election(&self, token: CancellationToken) {
let mut election_interval = interval(DNS_SERVER_ELECTION_INTERVAL);
election_interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
loop {
tokio::select! {
biased;
_ = token.cancelled() => {
tracing::info!("DnsNode received shutdown signal, exiting election loop");
break;
}
_ = self.elect.notified() => {}
_ = election_interval.tick() => {}
}
tracing::info!("trying to become DNS server");
let mut rpc =
StandAloneServer::new(TcpTunnelListener::new(DNS_SERVER_RPC_ADDR.clone()));
if rpc.serve().await.is_err() {
// Another node already owns the address — that's fine.
tracing::info!(
"failed to bind RPC server, another node might have won the election"
);
continue;
}
tracing::info!("won DNS server election, starting DnsServer");
let server = Arc::new(DnsServer::new(
self.peer_mgr.clone(),
self.global_ctx.clone(),
#[cfg(feature = "tun")]
self.nic_ctx.clone(),
));
server.register(&rpc);
server.run(token.child_token()).await;
tracing::warn!("DnsServer exited, will retry election");
}
}
#[instrument(skip_all, name = "DnsNode main loop")]
async fn run(&self, token: CancellationToken) {
let mut rpc = StandAloneClient::new(TcpTunnelConnector::new(DNS_SERVER_RPC_ADDR.clone()));
let mut heartbeat = HeartbeatRequest {
id: Some(self.id().into()),
..Default::default()
};
let mut heartbeat_interval = interval(DNS_NODE_HEARTBEAT_INTERVAL);
heartbeat_interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
let mut reconcile_interval = interval(DNS_NODE_RECONCILE_INTERVAL);
reconcile_interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
let mut subscriber = self.global_ctx.subscribe();
let mut tasks = JoinSet::new();
loop {
tokio::select! {
biased;
_ = token.cancelled() => {
tracing::info!("DnsNode received shutdown signal, exiting main loop");
break;
}
_ = heartbeat_interval.tick() => {
if let Err(error) = self.heartbeat(&mut rpc, &mut heartbeat).await {
tracing::error!(?error, "heartbeat failed");
self.elect.notify_one();
}
}
_ = reconcile_interval.tick() => {
let mgr = self.mgr.clone();
tasks.spawn(async move {
mgr.reconcile().await;
});
}
_ = self.mgr.dirty.wait() => {}
event = subscriber.recv() => {
match event {
Ok(GlobalCtxEvent::PeerInfoUpdated(peer_ids)) => {
for peer_id in peer_ids {
let mgr = self.mgr.clone();
tasks.spawn(async move {
if let Err(error) = mgr.refresh(peer_id, DNS_PEER_REFRESH_ATTEMPTS, DNS_PEER_REFRESH_BACKOFF).await {
tracing::error!(?error, ?peer_id, "failed to refresh peer");
}
});
}
continue;
}
Ok(
GlobalCtxEvent::DhcpIpv4Changed(..)
| GlobalCtxEvent::DhcpIpv4Conflicted(..),
) => {
tracing::info!(?event, "ip change detected, rebuilding snapshot");
}
Ok(GlobalCtxEvent::ConfigPatched(patch)) => {
// TODO: inspect patch
tracing::info!(?patch, "config change detected, rebuilding snapshot");
}
Err(broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!("event listener lagged, skipped {n} events, rebuilding snapshot");
}
Err(broadcast::error::RecvError::Closed) => {
tracing::info!("event bus closed");
break;
}
_ => continue,
}
self.mgr.dirty.mark();
}
result = tasks.join_next(), if !tasks.is_empty() => {
if let Some(Err(error)) = result {
tracing::error!(?error, "refresh task panicked");
}
}
}
}
}
async fn heartbeat(
&self,
rpc: &mut StandAloneClient<TcpTunnelConnector>,
heartbeat: &mut HeartbeatRequest,
) -> anyhow::Result<()> {
let request = if heartbeat.snapshot.is_none() || self.mgr.dirty.reset() {
heartbeat.update(self.mgr.snapshot());
heartbeat.clone()
} else {
let snapshot = heartbeat.snapshot.take();
let request = heartbeat.clone();
heartbeat.snapshot = snapshot;
request
};
let client = rpc
.scoped_client::<DnsNodeMgrRpcClientFactory<BaseController>>("".to_string())
.await?;
let response = client.heartbeat(BaseController::default(), request).await?;
if response.resync {
tracing::trace!("resync requested by server, sending full snapshot");
client
.heartbeat(BaseController::default(), heartbeat.clone())
.await?;
}
Ok(())
}
}
#[derive(Debug)]
pub struct DnsNode {
runtime: DnsNodeRuntime,
task: Option<CancellableTask<()>>,
}
impl DnsNode {
pub fn new(
peer_mgr: Arc<PeerManager>,
global_ctx: ArcGlobalCtx,
#[cfg(feature = "tun")] nic_ctx: ArcNicCtx, // TODO: REMOVE THIS
) -> Self {
let runtime = DnsNodeRuntime {
mgr: DnsPeerMgr::new(peer_mgr.clone(), global_ctx.clone()),
#[cfg(feature = "tun")]
nic_ctx,
peer_mgr,
global_ctx,
elect: Default::default(),
};
Self {
runtime,
task: None,
}
}
pub fn start(&mut self) {
let runtime = self.runtime.clone();
self.task
.replace(CancellableTask::spawn(|token| async move {
runtime.elect.notify_one();
tokio::join!(runtime.run_election(token.clone()), runtime.run(token));
}));
self.runtime.mgr.register();
}
pub async fn stop(&mut self) -> io::Result<()> {
self.runtime.mgr.unregister();
let Some(task) = self.task.take() else {
return Ok(());
};
task.stop(None).await
}
}
impl Drop for DnsNode {
fn drop(&mut self) {
self.runtime.mgr.unregister();
}
}
#[cfg(all(test, feature = "tun"))]
mod tests {
use super::*;
use crate::common::global_ctx::GlobalCtxEvent;
use crate::peers::tests::create_mock_peer_manager;
use crate::proto::api::config::InstanceConfigPatch;
use crate::proto::dns::{DnsNodeMgrRpc, DnsNodeMgrRpcServer, HeartbeatResponse};
use crate::proto::rpc_impl::standalone::StandAloneServer;
use crate::proto::rpc_types;
use crate::tunnel::common::tests::wait_for_condition;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio::sync::Mutex;
use tokio::time::sleep;
use url::Url;
#[derive(Debug)]
struct RecordingDnsNodeMgr {
requests: Mutex<Vec<HeartbeatRequest>>,
resync_on_first: AtomicBool,
}
impl RecordingDnsNodeMgr {
fn new(resync_on_first: bool) -> Self {
Self {
requests: Mutex::new(Vec::new()),
resync_on_first: AtomicBool::new(resync_on_first),
}
}
async fn recorded_requests(&self) -> Vec<HeartbeatRequest> {
self.requests.lock().await.clone()
}
}
#[async_trait::async_trait]
impl DnsNodeMgrRpc for RecordingDnsNodeMgr {
type Controller = BaseController;
async fn heartbeat(
&self,
_: Self::Controller,
input: HeartbeatRequest,
) -> rpc_types::error::Result<HeartbeatResponse> {
let mut requests = self.requests.lock().await;
requests.push(input);
let is_first = requests.len() == 1;
let resync = is_first && self.resync_on_first.load(Ordering::Relaxed);
if is_first {
self.resync_on_first.store(false, Ordering::Relaxed);
}
Ok(HeartbeatResponse { resync })
}
}
async fn build_test_runtime() -> DnsNodeRuntime {
let peer_mgr = create_mock_peer_manager().await;
let global_ctx = peer_mgr.get_global_ctx();
let nic_ctx: ArcNicCtx = Arc::new(Mutex::new(None));
DnsNodeRuntime {
mgr: DnsPeerMgr::new(peer_mgr.clone(), global_ctx.clone()),
nic_ctx,
peer_mgr,
global_ctx,
elect: Default::default(),
}
}
async fn start_recording_rpc_server(
rpc_addr: Url,
resync_on_first: bool,
) -> anyhow::Result<(
Arc<RecordingDnsNodeMgr>,
StandAloneServer<TcpTunnelListener>,
)> {
let mgr = Arc::new(RecordingDnsNodeMgr::new(resync_on_first));
let mut server = StandAloneServer::new(TcpTunnelListener::new(rpc_addr));
server
.registry()
.register(DnsNodeMgrRpcServer::new_arc(mgr.clone()), "");
server.serve().await?;
sleep(Duration::from_millis(50)).await;
Ok((mgr, server))
}
async fn occupy_dns_rpc_addr(rpc_addr: Url) -> StandAloneServer<TcpTunnelListener> {
let mut server = StandAloneServer::new(TcpTunnelListener::new(rpc_addr));
server.serve().await.unwrap();
server
}
#[tokio::test]
async fn heartbeat_first_send_includes_snapshot() {
let rpc_addr = Url::parse(&format!("tcp://127.0.0.1:{}", 49851)).unwrap();
let (_mgr, server) = start_recording_rpc_server(rpc_addr.clone(), false)
.await
.unwrap();
let node = build_test_runtime().await;
let mut rpc = StandAloneClient::new(TcpTunnelConnector::new(rpc_addr));
let mut heartbeat = HeartbeatRequest {
id: Some(node.id().into()),
..Default::default()
};
node.heartbeat(&mut rpc, &mut heartbeat).await.unwrap();
drop(server);
sleep(Duration::from_millis(50)).await;
assert!(heartbeat.snapshot.is_some());
assert!(!heartbeat.digest.is_empty());
}
#[tokio::test]
async fn heartbeat_clean_send_digest_only() {
let rpc_addr = Url::parse(&format!("tcp://127.0.0.1:{}", 49852)).unwrap();
let (mgr, server) = start_recording_rpc_server(rpc_addr.clone(), false)
.await
.unwrap();
let node = build_test_runtime().await;
let mut rpc = StandAloneClient::new(TcpTunnelConnector::new(rpc_addr));
let mut heartbeat = HeartbeatRequest {
id: Some(node.id().into()),
..Default::default()
};
node.heartbeat(&mut rpc, &mut heartbeat).await.unwrap();
let _ = node.mgr.dirty.reset();
node.heartbeat(&mut rpc, &mut heartbeat).await.unwrap();
let requests = mgr.recorded_requests().await;
drop(server);
sleep(Duration::from_millis(50)).await;
assert_eq!(requests.len(), 2);
assert!(requests[0].snapshot.is_some());
assert!(requests[1].snapshot.is_none());
assert_eq!(requests[0].digest, requests[1].digest);
}
#[tokio::test]
async fn heartbeat_dirty_forces_full_snapshot() {
let rpc_addr = Url::parse(&format!("tcp://127.0.0.1:{}", 49853)).unwrap();
let (mgr, server) = start_recording_rpc_server(rpc_addr.clone(), false)
.await
.unwrap();
let node = build_test_runtime().await;
let mut rpc = StandAloneClient::new(TcpTunnelConnector::new(rpc_addr));
let mut heartbeat = HeartbeatRequest {
id: Some(node.id().into()),
..Default::default()
};
node.heartbeat(&mut rpc, &mut heartbeat).await.unwrap();
node.mgr.dirty.mark();
node.heartbeat(&mut rpc, &mut heartbeat).await.unwrap();
let requests = mgr.recorded_requests().await;
drop(server);
sleep(Duration::from_millis(50)).await;
assert_eq!(requests.len(), 2);
assert!(requests[0].snapshot.is_some());
assert!(requests[1].snapshot.is_some());
}
#[tokio::test]
async fn heartbeat_resync_triggers_second_send() {
let rpc_addr = Url::parse(&format!("tcp://127.0.0.1:{}", 49854)).unwrap();
let (mgr, server) = start_recording_rpc_server(rpc_addr.clone(), true)
.await
.unwrap();
let node = build_test_runtime().await;
let mut rpc = StandAloneClient::new(TcpTunnelConnector::new(rpc_addr));
let mut heartbeat = HeartbeatRequest {
id: Some(node.id().into()),
..Default::default()
};
node.heartbeat(&mut rpc, &mut heartbeat).await.unwrap();
let requests = mgr.recorded_requests().await;
drop(server);
sleep(Duration::from_millis(50)).await;
assert_eq!(requests.len(), 2);
assert!(requests[0].snapshot.is_some());
assert!(requests[1].snapshot.is_some());
}
#[tokio::test]
#[serial_test::serial(dns_node_rpc_addr)]
async fn run_marks_dirty_on_dhcp_event() {
let node = build_test_runtime().await;
let _ = node.mgr.dirty.reset();
assert!(!node.mgr.dirty.peek());
let token = CancellationToken::new();
let handle = tokio::spawn({
let node = node.clone();
let token = token.clone();
async move { node.run(token).await }
});
sleep(Duration::from_millis(50)).await;
node.global_ctx
.issue_event(GlobalCtxEvent::DhcpIpv4Changed(None, None));
wait_for_condition(async || node.mgr.dirty.peek(), Duration::from_secs(2)).await;
token.cancel();
tokio::time::timeout(Duration::from_secs(2), handle)
.await
.unwrap()
.unwrap();
}
#[tokio::test]
async fn run_marks_dirty_on_config_patched_event() {
let node = build_test_runtime().await;
let _ = node.mgr.dirty.reset();
assert!(!node.mgr.dirty.peek());
let token = CancellationToken::new();
let handle = tokio::spawn({
let node = node.clone();
let token = token.clone();
async move { node.run(token).await }
});
sleep(Duration::from_millis(50)).await;
node.global_ctx
.issue_event(GlobalCtxEvent::ConfigPatched(InstanceConfigPatch::default()));
wait_for_condition(async || node.mgr.dirty.peek(), Duration::from_secs(2)).await;
token.cancel();
tokio::time::timeout(Duration::from_secs(2), handle)
.await
.unwrap()
.unwrap();
}
#[tokio::test]
async fn run_peer_info_updated_non_self_does_not_mark_dirty() {
let node = build_test_runtime().await;
let _ = node.mgr.dirty.reset();
assert!(!node.mgr.dirty.peek());
let token = CancellationToken::new();
let handle = tokio::spawn({
let node = node.clone();
let token = token.clone();
async move { node.run(token).await }
});
sleep(Duration::from_millis(50)).await;
node.global_ctx
.issue_event(GlobalCtxEvent::PeerInfoUpdated(vec![u32::MAX]));
sleep(Duration::from_millis(200)).await;
assert!(!node.mgr.dirty.peek());
token.cancel();
tokio::time::timeout(Duration::from_secs(2), handle)
.await
.unwrap()
.unwrap();
}
#[tokio::test]
async fn run_heartbeat_error_notifies_election() {
let node = build_test_runtime().await;
let _ = node.mgr.dirty.reset();
let token = CancellationToken::new();
let notified = node.elect.notified();
let handle = tokio::spawn({
let node = node.clone();
let token = token.clone();
async move { node.run(token).await }
});
tokio::time::timeout(2 * DNS_NODE_HEARTBEAT_INTERVAL, notified)
.await
.expect("heartbeat failure should notify election");
token.cancel();
tokio::time::timeout(Duration::from_secs(5), handle)
.await
.unwrap()
.unwrap();
}
}
+561
View File
@@ -0,0 +1,561 @@
use crate::dns::config::DNS_NODE_TTI;
use crate::dns::utils::addr::NameServerAddr;
use crate::dns::zone::{Zone, ZoneGroup};
use crate::proto::dns::DnsNodeMgrRpc;
use crate::proto::dns::{DnsSnapshot, HeartbeatRequest, HeartbeatResponse};
use crate::proto::rpc_types;
use crate::proto::rpc_types::controller::BaseController;
use crate::proto::utils::TransientDigest;
use crate::utils::dirty::DirtyFlag;
use anyhow::Error;
use hickory_server::zone_handler::Catalog;
use itertools::Itertools;
use moka::future::Cache;
use std::collections::HashSet;
use uuid::Uuid;
#[derive(Debug, Clone, Default)]
struct DnsNodeInfo {
digest: [u8; 32],
zones: ZoneGroup,
addresses: HashSet<NameServerAddr>,
listeners: HashSet<NameServerAddr>,
}
impl TryFrom<&DnsSnapshot> for DnsNodeInfo {
type Error = Error;
fn try_from(value: &DnsSnapshot) -> Result<Self, Self::Error> {
Ok(Self {
digest: value.digest(),
zones: value.zones.as_slice().try_into()?,
addresses: value
.addresses
.iter()
.map(TryInto::try_into)
.collect::<Result<_, _>>()?,
listeners: value
.listeners
.iter()
.map(TryInto::try_into)
.collect::<Result<_, _>>()?,
})
}
}
#[derive(Debug, Default)]
pub struct DnsNodeMgrDirtyFlags {
pub catalog: DirtyFlag,
pub addresses: DirtyFlag,
pub listeners: DirtyFlag,
}
#[derive(Debug)]
pub struct DnsNodeMgr {
nodes: Cache<Uuid, DnsNodeInfo>,
pub dirty: DnsNodeMgrDirtyFlags,
}
impl DnsNodeMgr {
pub fn new() -> Self {
Self {
nodes: Cache::builder().time_to_idle(DNS_NODE_TTI).build(),
dirty: Default::default(),
}
}
pub fn catalog(&self) -> Catalog {
let groups = self.collect_zones().into_groups();
tracing::trace!("building catalog with zones: {:?}", groups);
let system = Zone::system().create_forward_zone_handler();
groups
.into_iter()
.fold(Catalog::new(), |mut catalog, (origin, zones)| {
catalog.upsert(
origin.clone(),
zones
.iter_zone_handlers()
.chain(system.iter().cloned())
.collect(),
);
catalog
})
}
pub fn collect_zones(&self) -> ZoneGroup {
let mut zones = Vec::new();
let mut local = HashSet::new();
for (_, info) in self.nodes.iter() {
zones.extend(info.zones);
local.extend(info.addresses);
local.extend(info.listeners);
}
zones.push(Zone::system());
for forward in zones.iter_mut().flat_map(|z| &mut z.forward) {
forward.name_servers.retain_mut(|ns| {
ns.connections
.retain(|c| !local.contains(&(ns.ip, c).into()));
!ns.connections.is_empty()
});
}
zones.into()
}
pub fn iter_addresses(&self) -> impl Iterator<Item = NameServerAddr> + use<'_> {
self.nodes
.iter()
.flat_map(|(_, info)| info.addresses)
.unique()
}
pub fn iter_listeners(&self) -> impl Iterator<Item = NameServerAddr> + use<'_> {
self.nodes
.iter()
.flat_map(|(_, info)| info.listeners)
.unique()
}
}
#[async_trait::async_trait]
impl DnsNodeMgrRpc for DnsNodeMgr {
type Controller = BaseController;
async fn heartbeat(
&self,
_: BaseController,
input: HeartbeatRequest,
) -> rpc_types::error::Result<HeartbeatResponse> {
let id = input
.id
.ok_or(anyhow::anyhow!(
"missing id in heartbeat request: {:?}",
input
))?
.into();
let resync = if let Some(snapshot) = input.snapshot.as_ref() {
let new = DnsNodeInfo::try_from(snapshot)?;
let old = self.nodes.get(&id).await.unwrap_or_default();
if new.digest != old.digest {
self.dirty.catalog.mark();
if new.addresses != old.addresses {
self.dirty.addresses.mark();
}
if new.listeners != old.listeners {
self.dirty.listeners.mark();
}
self.nodes.insert(id, new).await;
}
false
} else {
self.nodes
.get(&id)
.await
.is_none_or(|info| input.digest != info.digest)
};
Ok(HeartbeatResponse { resync })
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dns::tests::{
dns_snapshot_with as snapshot_with, heartbeat_with_snapshot, new_request,
zone_data_a_with_forwarders as valid_zone_data,
};
use crate::dns::utils::response::ResponseHandle;
use hickory_proto::op::{Message, ResponseCode};
use hickory_proto::rr::{RData, RecordType};
use std::net::Ipv4Addr;
use tokio::time::{Duration, sleep};
fn heartbeat_digest_only(id: Uuid, digest: Vec<u8>) -> HeartbeatRequest {
HeartbeatRequest {
id: Some(id.into()),
digest,
snapshot: None,
}
}
fn reset_all_dirty(mgr: &DnsNodeMgr) {
let _ = mgr.dirty.catalog.reset();
let _ = mgr.dirty.addresses.reset();
let _ = mgr.dirty.listeners.reset();
}
async fn send_heartbeat(mgr: &DnsNodeMgr, input: HeartbeatRequest) -> HeartbeatResponse {
DnsNodeMgrRpc::heartbeat(mgr, BaseController::default(), input)
.await
.expect("heartbeat should succeed")
}
fn ns(s: &str) -> NameServerAddr {
s.parse().expect("invalid nameserver")
}
async fn lookup_a_record(mgr: &DnsNodeMgr, name: &str) -> anyhow::Result<Message> {
let request = new_request(name, RecordType::A)?;
let response = ResponseHandle::new(512);
let info = mgr
.catalog()
.lookup(&request, None, 0, response.clone())
.await;
assert_eq!(info.response_code, ResponseCode::NoError);
let response = response.into_inner().expect("response should exist");
Message::from_vec(&response).map_err(Into::into)
}
#[tokio::test]
async fn catalog_lookup_returns_record_after_snapshot_heartbeat() -> anyhow::Result<()> {
let mgr = DnsNodeMgr::new();
let id = Uuid::new_v4();
let snapshot = snapshot_with(
vec![valid_zone_data("catalog.test", "10.20.30.40", vec![])],
vec![],
vec![],
);
let _ = send_heartbeat(&mgr, heartbeat_with_snapshot(id, snapshot)).await;
let message = lookup_a_record(&mgr, "catalog.test.").await?;
assert!(message.answers.iter().any(|record| {
matches!(
record.data,
RData::A(addr) if *addr == Ipv4Addr::new(10, 20, 30, 40)
)
}));
Ok(())
}
#[tokio::test]
async fn catalog_lookup_aggregates_records_from_multiple_nodes() -> anyhow::Result<()> {
let mgr = DnsNodeMgr::new();
let snap_a = snapshot_with(
vec![valid_zone_data("node-a.test", "10.11.12.13", vec![])],
vec!["udp://10.0.1.1:53"],
vec![],
);
let snap_b = snapshot_with(
vec![valid_zone_data("node-b.test", "10.21.22.23", vec![])],
vec!["udp://10.0.2.1:53"],
vec![],
);
let _ = send_heartbeat(&mgr, heartbeat_with_snapshot(Uuid::new_v4(), snap_a)).await;
let _ = send_heartbeat(&mgr, heartbeat_with_snapshot(Uuid::new_v4(), snap_b)).await;
let message_a = lookup_a_record(&mgr, "node-a.test.").await?;
let message_b = lookup_a_record(&mgr, "node-b.test.").await?;
assert!(message_a.answers.iter().any(|record| {
matches!(
record.data,
RData::A(addr) if *addr == Ipv4Addr::new(10, 11, 12, 13)
)
}));
assert!(message_b.answers.iter().any(|record| {
matches!(
record.data,
RData::A(addr) if *addr == Ipv4Addr::new(10, 21, 22, 23)
)
}));
Ok(())
}
#[tokio::test]
async fn heartbeat_digest_only_resync_behavior() {
let mgr = DnsNodeMgr::new();
let id = Uuid::new_v4();
let first = send_heartbeat(&mgr, heartbeat_digest_only(id, vec![1, 2, 3])).await;
assert!(first.resync);
let snapshot = snapshot_with(
vec![valid_zone_data("resync.test", "10.0.0.10", vec![])],
vec!["udp://10.0.0.1:53"],
vec!["udp://10.0.0.2:53"],
);
let digest = snapshot.digest();
let full = send_heartbeat(&mgr, heartbeat_with_snapshot(id, snapshot)).await;
assert!(!full.resync);
let same = send_heartbeat(&mgr, heartbeat_digest_only(id, digest.into())).await;
assert!(!same.resync);
let different = send_heartbeat(&mgr, heartbeat_digest_only(id, vec![9, 9, 9])).await;
assert!(different.resync);
}
#[tokio::test]
async fn heartbeat_with_snapshot_marks_dirty_flags_by_field_changes() {
let mgr = DnsNodeMgr::new();
let id = Uuid::new_v4();
reset_all_dirty(&mgr);
let first = snapshot_with(
vec![valid_zone_data("dirty.test", "10.0.0.1", vec![])],
vec!["udp://10.10.10.1:53"],
vec!["udp://10.10.10.2:53"],
);
let _ = send_heartbeat(&mgr, heartbeat_with_snapshot(id, first)).await;
assert!(mgr.dirty.catalog.peek());
assert!(mgr.dirty.addresses.peek());
assert!(mgr.dirty.listeners.peek());
reset_all_dirty(&mgr);
let record_changed = snapshot_with(
vec![valid_zone_data("dirty.test", "10.0.0.2", vec![])],
vec!["udp://10.10.10.1:53"],
vec!["udp://10.10.10.2:53"],
);
let _ = send_heartbeat(&mgr, heartbeat_with_snapshot(id, record_changed)).await;
assert!(mgr.dirty.catalog.peek());
assert!(!mgr.dirty.addresses.peek());
assert!(!mgr.dirty.listeners.peek());
reset_all_dirty(&mgr);
let addr_listener_changed = snapshot_with(
vec![valid_zone_data("dirty.test", "10.0.0.2", vec![])],
vec!["udp://10.10.10.10:53"],
vec!["udp://10.10.10.20:53"],
);
let _ = send_heartbeat(&mgr, heartbeat_with_snapshot(id, addr_listener_changed)).await;
assert!(mgr.dirty.catalog.peek());
assert!(mgr.dirty.addresses.peek());
assert!(mgr.dirty.listeners.peek());
}
#[tokio::test]
async fn heartbeat_with_same_snapshot_digest_is_noop_for_dirty() {
let mgr = DnsNodeMgr::new();
let id = Uuid::new_v4();
let snapshot = snapshot_with(
vec![valid_zone_data("stable.test", "10.30.40.50", vec![])],
vec!["udp://10.3.0.1:53"],
vec!["udp://10.3.0.2:53"],
);
let _ = send_heartbeat(&mgr, heartbeat_with_snapshot(id, snapshot.clone())).await;
reset_all_dirty(&mgr);
let _ = send_heartbeat(&mgr, heartbeat_with_snapshot(id, snapshot)).await;
assert!(!mgr.dirty.catalog.peek());
assert!(!mgr.dirty.addresses.peek());
assert!(!mgr.dirty.listeners.peek());
}
#[tokio::test]
async fn heartbeat_missing_id_returns_error() {
let mgr = DnsNodeMgr::new();
let err =
DnsNodeMgrRpc::heartbeat(&mgr, BaseController::default(), HeartbeatRequest::default())
.await
.expect_err("missing id should error");
assert!(err.to_string().contains("missing id"));
}
#[tokio::test]
async fn iter_addresses_and_listeners_deduplicate_across_multiple_nodes() -> anyhow::Result<()>
{
let mgr = DnsNodeMgr::new();
let zone_a = Zone::try_from(&valid_zone_data("iter-a.test", "10.1.1.1", vec![]))?;
let zone_b = Zone::try_from(&valid_zone_data("iter-b.test", "10.1.1.2", vec![]))?;
mgr.nodes
.insert(
Uuid::new_v4(),
DnsNodeInfo {
digest: [1; 32],
zones: vec![zone_a].into(),
addresses: [ns("udp://10.100.0.1:53"), ns("udp://10.100.0.2:53")]
.into_iter()
.collect(),
listeners: [ns("udp://10.200.0.1:53")].into_iter().collect(),
},
)
.await;
mgr.nodes
.insert(
Uuid::new_v4(),
DnsNodeInfo {
digest: [2; 32],
zones: vec![zone_b].into(),
addresses: [ns("udp://10.100.0.2:53"), ns("udp://10.100.0.3:53")]
.into_iter()
.collect(),
listeners: [ns("udp://10.200.0.1:53"), ns("udp://10.200.0.2:53")]
.into_iter()
.collect(),
},
)
.await;
let addresses: HashSet<_> = mgr.iter_addresses().collect();
let listeners: HashSet<_> = mgr.iter_listeners().collect();
assert_eq!(addresses.len(), 3);
assert!(addresses.contains(&ns("udp://10.100.0.1:53")));
assert!(addresses.contains(&ns("udp://10.100.0.2:53")));
assert!(addresses.contains(&ns("udp://10.100.0.3:53")));
assert_eq!(listeners.len(), 2);
assert!(listeners.contains(&ns("udp://10.200.0.1:53")));
assert!(listeners.contains(&ns("udp://10.200.0.2:53")));
Ok(())
}
#[tokio::test]
async fn collect_zones_filters_out_local_forwarders() -> anyhow::Result<()> {
let mgr = DnsNodeMgr::new();
let zone = Zone::try_from(&valid_zone_data(
"filter-loop.test",
"10.2.3.4",
vec![
"udp://10.0.0.10:53",
"tcp://10.0.0.11:53",
"udp://1.1.1.1:53",
],
))?;
mgr.nodes
.insert(
Uuid::new_v4(),
DnsNodeInfo {
digest: [1; 32],
zones: vec![zone].into(),
addresses: [ns("udp://10.0.0.10:53")].into_iter().collect(),
listeners: [ns("tcp://10.0.0.11:53")].into_iter().collect(),
},
)
.await;
let zones: Vec<_> = mgr.collect_zones().into_iter().map(Into::into).collect();
let loop_zone = zones
.into_iter()
.find(|z: &crate::proto::dns::ZoneData| z.content.contains("$ORIGIN filter-loop.test"))
.expect("test zone should exist");
let forwarders: HashSet<NameServerAddr> = loop_zone
.forwarders
.iter()
.map(|u| NameServerAddr::try_from(u).expect("forwarder should be valid"))
.collect();
assert_eq!(forwarders.len(), 1);
assert!(forwarders.contains(&ns("udp://1.1.1.1:53")));
Ok(())
}
#[tokio::test]
async fn collect_zones_filters_cross_node_local_forwarders() -> anyhow::Result<()> {
let mgr = DnsNodeMgr::new();
let node_a = snapshot_with(
vec![valid_zone_data(
"cross-node-filter.test",
"10.8.8.8",
vec![
"udp://10.50.0.1:53",
"udp://10.50.0.2:53",
"udp://8.8.8.8:53",
],
)],
vec!["udp://10.50.0.1:53"],
vec![],
);
let node_b = snapshot_with(
vec![valid_zone_data(
"cross-node-helper.test",
"10.9.9.9",
vec![],
)],
vec![],
vec!["udp://10.50.0.2:53"],
);
let _ = send_heartbeat(&mgr, heartbeat_with_snapshot(Uuid::new_v4(), node_a)).await;
let _ = send_heartbeat(&mgr, heartbeat_with_snapshot(Uuid::new_v4(), node_b)).await;
let zones: Vec<_> = mgr.collect_zones().into_iter().map(Into::into).collect();
let zone = zones
.into_iter()
.find(|z: &crate::proto::dns::ZoneData| {
z.content.contains("$ORIGIN cross-node-filter.test")
})
.expect("test zone should exist");
let forwarders: HashSet<NameServerAddr> = zone
.forwarders
.iter()
.map(|u| NameServerAddr::try_from(u).expect("forwarder should be valid"))
.collect();
assert_eq!(forwarders.len(), 1);
assert!(forwarders.contains(&ns("udp://8.8.8.8:53")));
Ok(())
}
#[tokio::test]
async fn heartbeat_digest_resync_is_node_scoped() {
let mgr = DnsNodeMgr::new();
let node_a = Uuid::new_v4();
let node_b = Uuid::new_v4();
let snap_a = snapshot_with(
vec![valid_zone_data("scope-a.test", "10.60.0.1", vec![])],
vec!["udp://10.60.0.2:53"],
vec![],
);
let digest_a = snap_a.digest();
let _ = send_heartbeat(&mgr, heartbeat_with_snapshot(node_a, snap_a)).await;
let a_same = send_heartbeat(&mgr, heartbeat_digest_only(node_a, digest_a.into())).await;
assert!(!a_same.resync);
let b_unknown = send_heartbeat(&mgr, heartbeat_digest_only(node_b, vec![1, 2, 3])).await;
assert!(b_unknown.resync);
}
#[tokio::test]
async fn heartbeat_resync_after_node_idle_ttl_expiry() {
let mgr = DnsNodeMgr::new();
let id = Uuid::new_v4();
let snapshot = snapshot_with(
vec![valid_zone_data("ttl.test", "10.9.9.9", vec![])],
vec!["udp://10.9.0.1:53"],
vec![],
);
let digest = snapshot.digest();
let _ = send_heartbeat(&mgr, heartbeat_with_snapshot(id, snapshot)).await;
let before_expiry = send_heartbeat(&mgr, heartbeat_digest_only(id, digest.to_vec())).await;
assert!(!before_expiry.resync);
sleep(DNS_NODE_TTI + Duration::from_millis(300)).await;
let after_expiry = send_heartbeat(&mgr, heartbeat_digest_only(id, digest.into())).await;
assert!(after_expiry.resync);
}
}
+873
View File
@@ -0,0 +1,873 @@
use crate::common::PeerId;
use crate::common::global_ctx::ArcGlobalCtx;
use crate::dns::config::zone::ZoneConfig;
use crate::dns::config::{
DNS_PEER_REFRESH_ATTEMPTS, DNS_PEER_REFRESH_BACKOFF, DNS_PEER_TTI, DnsExportConfig,
DnsGlobalCtxExt,
};
use crate::dns::zone::ZoneGroup;
use crate::peer_center::instance::PeerCenterPeerManagerTrait;
use crate::peers::peer_manager::PeerManager;
use crate::peers::route_trait::Route;
use crate::proto::dns::{
DnsPeerMgrRpc, DnsPeerMgrRpcClientFactory, DnsPeerMgrRpcServer, DnsSnapshot,
GetExportConfigRequest, GetExportConfigResponse, ZoneData,
};
use crate::proto::rpc_types;
use crate::proto::rpc_types::controller::BaseController;
use crate::proto::utils::TransientDigest;
use crate::utils::dirty::DirtyFlag;
use anyhow::Context;
use futures::StreamExt;
use futures::stream;
use moka::future::Cache;
use std::ops::Deref;
use std::sync::Arc;
use std::time::Duration;
use tokio::time::sleep;
use tracing::instrument;
#[derive(Debug, Clone)]
struct DnsPeerInfo {
digest: [u8; 32],
zones: Vec<ZoneData>,
}
impl TryFrom<DnsExportConfig> for DnsPeerInfo {
type Error = anyhow::Error;
fn try_from(value: DnsExportConfig) -> Result<Self, Self::Error> {
let _ = ZoneGroup::try_from(value.zones.as_slice())?;
Ok(Self {
digest: value.digest(),
zones: value.zones,
})
}
}
#[derive(Debug)]
pub struct DnsPeerMgrInner {
peers: Cache<PeerId, DnsPeerInfo>,
pub dirty: DirtyFlag,
peer_mgr: Arc<PeerManager>,
global_ctx: ArcGlobalCtx,
}
impl DnsPeerMgrInner {
pub fn snapshot(&self) -> DnsSnapshot {
let global_ctx = &self.global_ctx;
let zones = global_ctx
.dns_iter_zones()
.map(ZoneConfig::into_data)
.chain(
self.peers
.iter()
.flat_map(|(_, info)| info.zones.into_iter()),
)
.collect();
let config = global_ctx.config.get_dns().into_parsed();
DnsSnapshot {
zones,
addresses: config.addresses.into(),
listeners: config.listeners.into(),
}
}
#[instrument(skip(self), level = "trace", ret)]
pub async fn refresh(
&self,
peer_id: PeerId,
mut attempts: usize,
mut backoff: Duration,
) -> anyhow::Result<bool> {
loop {
attempts = attempts.saturating_sub(1);
let result = self.try_refresh(peer_id).await;
match &result {
Ok(_) => {
tracing::trace!(?peer_id, "peer info refreshed");
return result;
}
Err(_) if attempts == 0 => {
self.peers.invalidate(&peer_id).await;
self.dirty.mark();
tracing::error!(
?peer_id,
"exhausted all attempts to refresh peer info, invalidating cache"
);
return result;
}
Err(error) => {
tracing::error!(
?error,
?peer_id,
"failed to refresh peer info, retrying in {:?}",
backoff
);
sleep(backoff).await;
backoff *= 2;
}
}
}
}
async fn try_refresh(&self, peer_id: PeerId) -> anyhow::Result<bool> {
if peer_id == self.peer_mgr.my_peer_id() {
self.dirty.mark();
return Ok(true);
}
let Some(route) = self.peer_mgr.get_route().get_peer_info(peer_id).await else {
if self.peers.remove(&peer_id).await.is_some() {
tracing::debug!(?peer_id, "peer route disappeared, removing from cache");
self.dirty.mark();
}
return Ok(true);
};
if self
.peers
.get(&peer_id)
.await
.is_some_and(|info| route.dns == info.digest)
{
return Ok(false);
}
if !route.dns.is_empty() {
let info = self.fetch(peer_id).await.with_context(|| {
format!("failed to fetch dns export config from peer {}", peer_id)
})?;
self.peers.insert(peer_id, info).await;
} else {
self.peers.invalidate(&peer_id).await;
}
self.dirty.mark();
Ok(true)
}
#[instrument(skip(self), level = "trace", ret)]
async fn fetch(&self, peer_id: PeerId) -> anyhow::Result<DnsPeerInfo> {
self.peer_mgr
.get_peer_rpc_mgr()
.rpc_client()
.scoped_client::<DnsPeerMgrRpcClientFactory<BaseController>>(
self.peer_mgr.my_peer_id(),
peer_id,
self.global_ctx.get_network_name(),
)
.get_export_config(BaseController::default(), GetExportConfigRequest {})
.await
.context("rpc call failed")?
.try_into()
}
}
#[async_trait::async_trait]
impl DnsPeerMgrRpc for DnsPeerMgrInner {
type Controller = BaseController;
async fn get_export_config(
&self,
_: Self::Controller,
_: GetExportConfigRequest,
) -> rpc_types::error::Result<GetExportConfigResponse> {
Ok(self.global_ctx.dns_export_config())
}
}
#[derive(Debug, Clone)]
pub struct DnsPeerMgr(Arc<DnsPeerMgrInner>);
impl DnsPeerMgr {
pub fn new(peer_mgr: Arc<PeerManager>, global_ctx: ArcGlobalCtx) -> Self {
Self(Arc::new(DnsPeerMgrInner {
peers: Cache::builder().time_to_idle(DNS_PEER_TTI).build(),
dirty: Default::default(),
peer_mgr,
global_ctx,
}))
}
pub fn register(&self) {
self.peer_mgr
.get_peer_rpc_mgr()
.rpc_server()
.registry()
.register(
DnsPeerMgrRpcServer::new_arc(self.0.clone()),
&self.global_ctx.get_network_name(),
);
}
pub fn unregister(&self) -> Option<()> {
self.peer_mgr
.get_peer_rpc_mgr()
.rpc_server()
.registry()
.unregister(
DnsPeerMgrRpcServer::new_arc(self.0.clone()),
&self.global_ctx.get_network_name(),
)
}
#[instrument(skip(self), level = "trace")]
pub async fn reconcile(&self) {
stream::iter(self.peer_mgr.list_routes().await.into_iter())
.map(|route| {
let peer_id = route.peer_id;
let this = self.clone();
async move {
if let Err(error) = this
.refresh(peer_id, DNS_PEER_REFRESH_ATTEMPTS, DNS_PEER_REFRESH_BACKOFF)
.await
{
tracing::error!(?error, ?peer_id, "failed to refresh peer info");
}
}
})
.buffer_unordered(32)
.collect::<Vec<_>>()
.await;
}
}
impl Deref for DnsPeerMgr {
type Target = DnsPeerMgrInner;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::common::global_ctx::tests::get_mock_global_ctx;
use crate::dns::config::zone::ZoneConfig;
use crate::dns::tests::zone_data_a as valid_zone_data;
use crate::peers::create_packet_recv_chan;
use crate::peers::peer_manager::RouteAlgoType;
use crate::peers::tests::{connect_peer_manager, wait_route_appear};
use crate::proto::dns::GetExportConfigRequest;
use std::collections::HashSet;
use std::net::Ipv4Addr;
use tokio::time::{Duration, sleep};
async fn create_peer_manager_with_zone(
host: &str,
origin: &str,
record_ip: Ipv4Addr,
) -> Arc<PeerManager> {
let ctx = get_mock_global_ctx();
let mut dns = ctx.config.get_dns().into_raw();
dns.name = Some(host.parse().unwrap());
dns.zones
.get_or_insert_default()
.push(ZoneConfig::dedicated(
origin.parse().expect("invalid zone origin"),
Some(record_ip),
vec![],
));
ctx.config.set_dns(dns.into());
let (s, _r) = create_packet_recv_chan();
let peer_mgr = Arc::new(PeerManager::new(RouteAlgoType::Ospf, ctx, s));
peer_mgr.run().await.unwrap();
peer_mgr
}
#[test]
fn dns_peer_info_try_from_valid_config() {
let cfg = DnsExportConfig {
zones: vec![valid_zone_data("valid.peer.test", "10.0.0.10")],
};
let info = DnsPeerInfo::try_from(cfg).expect("valid export config should pass");
assert_eq!(info.zones.len(), 1);
assert!(!info.digest.is_empty());
}
#[test]
fn dns_peer_info_try_from_invalid_zone_rejected() {
let cfg = DnsExportConfig {
zones: vec![ZoneData::new(&".".parse().unwrap(), 60, ["?"], [], [])],
};
assert!(DnsPeerInfo::try_from(cfg).is_err());
}
#[tokio::test]
async fn snapshot_merges_local_and_cached_peer_zones() {
let peer_mgr = create_peer_manager_with_zone(
"local-peer",
"local-custom.test",
Ipv4Addr::new(10, 10, 10, 10),
)
.await;
let global_ctx = peer_mgr.get_global_ctx();
let mgr = DnsPeerMgr::new(peer_mgr, global_ctx);
mgr.peers
.insert(
999_999,
DnsPeerInfo {
digest: [9; 32],
zones: vec![valid_zone_data("peer-cache.test", "10.20.30.40")],
},
)
.await;
let snapshot = mgr.snapshot();
assert!(
snapshot
.zones
.iter()
.any(|z| z.content.contains("$ORIGIN peer-cache.test"))
);
assert!(
snapshot
.zones
.iter()
.any(|z| z.content.contains("$ORIGIN local-custom.test"))
);
}
#[tokio::test]
async fn snapshot_includes_local_addresses_and_listeners() {
let peer_mgr = create_peer_manager_with_zone(
"local-addr-listener",
"local-addr-zone.test",
Ipv4Addr::new(10, 10, 11, 11),
)
.await;
let global_ctx = peer_mgr.get_global_ctx();
let expected = global_ctx.config.get_dns().into_parsed();
let mgr = DnsPeerMgr::new(peer_mgr, global_ctx);
let snapshot = mgr.snapshot();
let mut expected_addresses = expected
.addresses
.into_iter()
.map(|a| a.to_string())
.collect::<Vec<_>>();
let mut expected_listeners = expected
.listeners
.into_iter()
.map(|a| a.to_string())
.collect::<Vec<_>>();
let mut got_addresses = snapshot
.addresses
.into_iter()
.map(|a| a.to_string())
.collect::<Vec<_>>();
let mut got_listeners = snapshot
.listeners
.into_iter()
.map(|a| a.to_string())
.collect::<Vec<_>>();
expected_addresses.sort();
expected_listeners.sort();
got_addresses.sort();
got_listeners.sort();
assert_eq!(got_addresses, expected_addresses);
assert_eq!(got_listeners, expected_listeners);
}
#[tokio::test]
async fn snapshot_aggregates_zones_from_multiple_cached_peers() {
let peer_mgr = create_peer_manager_with_zone(
"local-multi",
"local-multi.test",
Ipv4Addr::new(10, 10, 12, 1),
)
.await;
let global_ctx = peer_mgr.get_global_ctx();
let mgr = DnsPeerMgr::new(peer_mgr, global_ctx);
mgr.peers
.insert(
11,
DnsPeerInfo {
digest: [11; 32],
zones: vec![valid_zone_data("peer-a.test", "10.20.30.41")],
},
)
.await;
mgr.peers
.insert(
12,
DnsPeerInfo {
digest: [12; 32],
zones: vec![valid_zone_data("peer-b.test", "10.20.30.42")],
},
)
.await;
let snapshot = mgr.snapshot();
let contents: HashSet<_> = snapshot.zones.into_iter().map(|z| z.content).collect();
assert!(contents.iter().any(|z| z.contains("$ORIGIN peer-a.test")));
assert!(contents.iter().any(|z| z.contains("$ORIGIN peer-b.test")));
assert!(
contents
.iter()
.any(|z| z.contains("$ORIGIN local-multi.test"))
);
}
#[tokio::test]
async fn snapshot_with_peer_without_zones_keeps_local_snapshot() {
let peer_mgr = create_peer_manager_with_zone(
"local-empty-peer-zone",
"local-empty-zone.test",
Ipv4Addr::new(10, 10, 13, 1),
)
.await;
let mgr = DnsPeerMgr::new(peer_mgr, get_mock_global_ctx());
let before = mgr.snapshot();
mgr.peers
.insert(
13,
DnsPeerInfo {
digest: [13; 32],
zones: vec![],
},
)
.await;
let after = mgr.snapshot();
assert_eq!(before.zones.len(), after.zones.len());
assert_eq!(before.addresses, after.addresses);
assert_eq!(before.listeners, after.listeners);
}
#[tokio::test]
async fn get_export_config_returns_global_ctx_export() {
let peer_mgr = create_peer_manager_with_zone(
"export-peer",
"exported-zone.test",
Ipv4Addr::new(10, 10, 20, 20),
)
.await;
let global_ctx = peer_mgr.get_global_ctx();
let mgr = DnsPeerMgr::new(peer_mgr, global_ctx.clone());
let got = DnsPeerMgrRpc::get_export_config(
mgr.0.as_ref(),
BaseController::default(),
GetExportConfigRequest {},
)
.await
.expect("get_export_config should succeed");
assert_eq!(got, global_ctx.dns_export_config());
}
#[tokio::test]
async fn refresh_self_peer_marks_dirty_only() {
let peer_mgr = create_peer_manager_with_zone(
"self-peer",
"self-zone.test",
Ipv4Addr::new(10, 0, 0, 1),
)
.await;
let mgr = DnsPeerMgr::new(peer_mgr.clone(), peer_mgr.get_global_ctx());
mgr.dirty.reset();
mgr.try_refresh(peer_mgr.my_peer_id()).await.unwrap();
assert!(mgr.dirty.peek());
}
#[tokio::test]
async fn refresh_missing_route_noop_and_not_dirty() {
let peer_mgr = create_peer_manager_with_zone(
"solo-peer",
"solo-zone.test",
Ipv4Addr::new(10, 0, 0, 2),
)
.await;
let mgr = DnsPeerMgr::new(peer_mgr, get_mock_global_ctx());
mgr.dirty.reset();
mgr.try_refresh(987_654).await.unwrap();
assert!(!mgr.dirty.peek());
}
#[tokio::test]
async fn refresh_same_digest_skips_fetch_and_not_mark_dirty() {
let local = create_peer_manager_with_zone(
"local-same",
"local-same.test",
Ipv4Addr::new(10, 0, 1, 1),
)
.await;
let remote = create_peer_manager_with_zone(
"remote-same",
"remote-same.test",
Ipv4Addr::new(10, 0, 1, 2),
)
.await;
connect_peer_manager(local.clone(), remote.clone()).await;
wait_route_appear(local.clone(), remote.clone())
.await
.expect("route should appear");
let remote_id = remote.my_peer_id();
let remote_route_dns = local
.get_route()
.get_peer_info(remote_id)
.await
.expect("remote route should exist")
.dns;
let mgr = DnsPeerMgr::new(local, get_mock_global_ctx());
mgr.peers
.insert(
remote_id,
DnsPeerInfo {
digest: remote_route_dns
.try_into()
.expect("route dns digest should be 32 bytes"),
zones: vec![valid_zone_data("cached-same.test", "10.0.1.9")],
},
)
.await;
mgr.dirty.reset();
mgr.try_refresh(remote_id).await.unwrap();
sleep(Duration::from_millis(50)).await;
assert!(!mgr.dirty.peek());
}
#[tokio::test]
async fn refresh_remote_peer_fetches_and_updates_snapshot() {
let local = create_peer_manager_with_zone(
"local-refresh",
"local-refresh.test",
Ipv4Addr::new(10, 0, 2, 1),
)
.await;
let remote = create_peer_manager_with_zone(
"remote-refresh",
"remote-export.test",
Ipv4Addr::new(10, 0, 2, 2),
)
.await;
let local_dns = DnsPeerMgr::new(local.clone(), local.get_global_ctx());
let remote_dns = DnsPeerMgr::new(remote.clone(), remote.get_global_ctx());
remote_dns.register();
connect_peer_manager(local.clone(), remote.clone()).await;
wait_route_appear(local.clone(), remote.clone())
.await
.expect("route should appear");
local_dns.dirty.reset();
local_dns.try_refresh(remote.my_peer_id()).await.unwrap();
assert!(local_dns.dirty.peek());
let snapshot = local_dns.snapshot();
assert!(
snapshot
.zones
.iter()
.any(|z| z.content.contains("$ORIGIN remote-export.test"))
);
}
#[tokio::test]
async fn multi_peer_refresh_updates_only_target_peer_snapshot_data() {
let local = create_peer_manager_with_zone(
"local-multi-refresh",
"local-multi-refresh.test",
Ipv4Addr::new(10, 2, 0, 1),
)
.await;
let peer_a =
create_peer_manager_with_zone("peer-a", "remote-a.test", Ipv4Addr::new(10, 2, 0, 2))
.await;
let peer_b =
create_peer_manager_with_zone("peer-b", "remote-b.test", Ipv4Addr::new(10, 2, 0, 3))
.await;
let local_dns = DnsPeerMgr::new(local.clone(), local.get_global_ctx());
let peer_a_dns = DnsPeerMgr::new(peer_a.clone(), peer_a.get_global_ctx());
peer_a_dns.register();
connect_peer_manager(local.clone(), peer_a.clone()).await;
connect_peer_manager(local.clone(), peer_b.clone()).await;
wait_route_appear(local.clone(), peer_a.clone())
.await
.expect("route to peer_a should appear");
wait_route_appear(local.clone(), peer_b.clone())
.await
.expect("route to peer_b should appear");
local_dns.try_refresh(peer_a.my_peer_id()).await.unwrap();
let snapshot = local_dns.snapshot();
assert!(
snapshot
.zones
.iter()
.any(|z| z.content.contains("$ORIGIN remote-a.test"))
);
assert!(
!snapshot
.zones
.iter()
.any(|z| z.content.contains("$ORIGIN remote-b.test"))
);
}
#[tokio::test]
async fn multi_peer_refresh_failure_invalidates_only_target_peer_cache() {
let local = create_peer_manager_with_zone(
"local-invalidate",
"local-invalidate.test",
Ipv4Addr::new(10, 2, 1, 1),
)
.await;
let fail_peer = create_peer_manager_with_zone(
"peer-fail",
"peer-fail.test",
Ipv4Addr::new(10, 2, 1, 2),
)
.await;
let keep_peer = create_peer_manager_with_zone(
"peer-keep",
"peer-keep.test",
Ipv4Addr::new(10, 2, 1, 3),
)
.await;
let local_dns = DnsPeerMgr::new(local.clone(), local.get_global_ctx());
local_dns.register();
let keep_dns = DnsPeerMgr::new(keep_peer.clone(), keep_peer.get_global_ctx());
keep_dns.register();
let fail_id = fail_peer.my_peer_id();
let keep_id = keep_peer.my_peer_id();
local_dns
.peers
.insert(
fail_id,
DnsPeerInfo {
digest: [1; 32],
zones: vec![valid_zone_data("cached-fail.test", "10.2.1.20")],
},
)
.await;
local_dns
.peers
.insert(
keep_id,
DnsPeerInfo {
digest: [2; 32],
zones: vec![valid_zone_data("cached-keep.test", "10.2.1.21")],
},
)
.await;
connect_peer_manager(local.clone(), fail_peer.clone()).await;
connect_peer_manager(local.clone(), keep_peer.clone()).await;
wait_route_appear(local.clone(), fail_peer.clone())
.await
.expect("route to fail_peer should appear");
wait_route_appear(local.clone(), keep_peer.clone())
.await
.expect("route to keep_peer should appear");
local_dns.dirty.reset();
local_dns
.refresh(fail_id, Default::default(), Default::default())
.await
.unwrap_err();
assert!(local_dns.dirty.peek());
assert!(local_dns.peers.get(&fail_id).await.is_none());
assert!(local_dns.peers.get(&keep_id).await.is_some());
}
#[tokio::test]
async fn multi_peer_mixed_digest_changes_only_mark_for_changed_peer() {
let local = create_peer_manager_with_zone(
"local-mixed",
"local-mixed.test",
Ipv4Addr::new(10, 2, 2, 1),
)
.await;
let changed_peer = create_peer_manager_with_zone(
"peer-changed",
"peer-changed.test",
Ipv4Addr::new(10, 2, 2, 2),
)
.await;
let unchanged_peer = create_peer_manager_with_zone(
"peer-unchanged",
"peer-unchanged.test",
Ipv4Addr::new(10, 2, 2, 3),
)
.await;
let local_dns = DnsPeerMgr::new(local.clone(), local.get_global_ctx());
let changed_dns = DnsPeerMgr::new(changed_peer.clone(), changed_peer.get_global_ctx());
let unchanged_dns =
DnsPeerMgr::new(unchanged_peer.clone(), unchanged_peer.get_global_ctx());
changed_dns.register();
unchanged_dns.register();
connect_peer_manager(local.clone(), changed_peer.clone()).await;
connect_peer_manager(local.clone(), unchanged_peer.clone()).await;
wait_route_appear(local.clone(), changed_peer.clone())
.await
.expect("route to changed_peer should appear");
wait_route_appear(local.clone(), unchanged_peer.clone())
.await
.expect("route to unchanged_peer should appear");
let unchanged_id = unchanged_peer.my_peer_id();
let unchanged_digest = local
.get_route()
.get_peer_info(unchanged_id)
.await
.expect("unchanged route should exist")
.dns;
local_dns
.peers
.insert(
changed_peer.my_peer_id(),
DnsPeerInfo {
digest: [0; 32],
zones: vec![valid_zone_data("stale-changed.test", "10.2.2.20")],
},
)
.await;
local_dns
.peers
.insert(
unchanged_id,
DnsPeerInfo {
digest: unchanged_digest
.try_into()
.expect("route dns digest should be 32 bytes"),
zones: vec![valid_zone_data("cached-unchanged.test", "10.2.2.21")],
},
)
.await;
local_dns.dirty.reset();
local_dns
.try_refresh(changed_peer.my_peer_id())
.await
.unwrap();
assert!(local_dns.dirty.peek());
local_dns.dirty.reset();
local_dns.try_refresh(unchanged_id).await.unwrap();
assert!(!local_dns.dirty.peek());
let unchanged_cache = local_dns
.peers
.get(&unchanged_id)
.await
.expect("unchanged peer cache should stay");
assert!(
unchanged_cache
.zones
.iter()
.any(|z| z.content.contains("$ORIGIN cached-unchanged.test"))
);
}
#[tokio::test]
async fn snapshot_removes_cached_peer_zone_after_tti_expire() {
let peer_mgr = create_peer_manager_with_zone(
"local-tti",
"local-tti.test",
Ipv4Addr::new(10, 3, 0, 1),
)
.await;
let global_ctx = peer_mgr.get_global_ctx();
let mgr = DnsPeerMgr::new(peer_mgr, global_ctx);
let cached_peer_id = 66_666;
mgr.peers
.insert(
cached_peer_id,
DnsPeerInfo {
digest: [6; 32],
zones: vec![valid_zone_data("cached-expire.test", "10.3.0.2")],
},
)
.await;
let before = mgr.snapshot();
assert!(
before
.zones
.iter()
.any(|z| z.content.contains("$ORIGIN cached-expire.test"))
);
assert!(
before
.zones
.iter()
.any(|z| z.content.contains("$ORIGIN local-tti.test"))
);
let deadline = tokio::time::Instant::now() + DNS_PEER_TTI + Duration::from_secs(3);
loop {
let now_snapshot = mgr.snapshot();
let expired = !now_snapshot
.zones
.iter()
.any(|z| z.content.contains("$ORIGIN cached-expire.test"));
if expired {
assert!(
now_snapshot
.zones
.iter()
.any(|z| z.content.contains("local-tti.test"))
);
break;
}
assert!(
tokio::time::Instant::now() < deadline,
"cached peer zone did not expire within expected TTI window"
);
sleep(Duration::from_millis(100)).await;
}
}
#[tokio::test]
async fn register_then_unregister_returns_some() {
let peer_mgr = create_peer_manager_with_zone(
"register-peer",
"register-zone.test",
Ipv4Addr::new(10, 1, 0, 1),
)
.await;
let mgr = DnsPeerMgr::new(peer_mgr.clone(), peer_mgr.get_global_ctx());
mgr.register();
assert!(mgr.unregister().is_some());
}
}
+163
View File
@@ -0,0 +1,163 @@
## 目标
`instance/dns_server` 重写为单独的 `dns` 模块,为如下的配置项提供支持:
```toml
[dns]
name = "localhost" # optional, replaces hostname, default to system hostname
domain = "localdomain" # optional, replaces tld_dns_zone, default to et.net
addresses = [
"100.100.100.101:53",
] # optional, default to [ "100.100.100.101:53" ]
# any UDP packet or ICMP packet to these addresses will be hijacked by the dns server
# the server does *not* bind to/listen on these addresses!
listeners = [
] # optional, default to empty
# let the dns server bind to these addresses
# could be useful when no_tun = true
# these two options supersede accept_dns
# setting both of them to empty is equivalent to set accept_dns = false, but zones are still broadcasted
# this policy applies to all zones with origin "example.com"
[dns."example.com".import]
whitelist = ["*"]
blacklist = []
disabled = true # optional, whether to reject zones with this origin from connected peers, default to false
recursive = true # optional, apply this policy to all subzones, default to false
[[dns.zone]]
origin = "example.com" # required, name of the zone
ttl = 3600 # optional, default to 0
records = [
"www 60 IN A 123.123.123.123",
"app IN CNAME www",
] # optional, custom DNS records
forwarders = [
"1.1.1.1",
] # optional, forward DNS requests to these servers
fallthrough = false # optional, whether to fall back to next zone (with same origin) if request doesn't match any record in this zone, default to true
# this policy applies to the current zone block
[dns.zone.export] # if present, export this zone to connected peers
whitelist = ["*"] # optional
blacklist = [] # optional
# same zone, but not exported
[[dns.zone]]
origin = "example.com"
forwarders = [
"tcp://192.168.0.53:5353",
]
```
<details>
<summary><h2>计划和进展</h2></summary>
每个 peer 会默认拥有一个专用 zone,它的 origin 是这个 peer 的 fqdn,唯一的记录是指向该 peer 的 ip 的 A、AAAA 记录
## protobuf
- `ZoneData`:包含 Zone 配置,以及一个 ID,该 ID 在读取 TOML 时生成
- `GetExportConfigResponse` (`DnsExportConfig`):包含全部 export 的 `ZoneData`(特别地,包含专用 zone)、该 peer 的 fqdn
- `HeartbeatRequest`: DnsNode 发送的心跳,包含:id、digest、`Option<Snapshot>`
- `DnsSnapshot`: 所有 DnsServer 需要的配置
## RoutePeerInfo
为预防用户提交大量自定义 DNS 记录导致 RoutePeerInfo 泛洪造成带宽压力:
-`RoutePeerInfo` 中只保存本地 DNS 配置的 hash
- 收到 `RoutePeerInfo` 后读取其中 DNS 的 hash,若与本地不同,通过 RPC 拉取 Peer 的 DNS 配置
## DnsNode
1. - [x] 监听配置更新/IP 地址变化,重建快照
2. - [x] (`GlobalCtx` 的扩展 trait) 使用自己的 name 和 domain 创建一个专用 zone,让 name 指向自身 IP(为 DNS 一致性避免使用 127.0.0.1 作为 IP,若没有 IP 则不创建这个 zone)
3. - [x] 每次获得 RoutePeerInfo 时,读取其中的 dns 字段(和一些别的身份标记字段),这是远程 Peer 的 dns 配置(不含 addresses 和 listeners)的 digest
- [x] 接收后检查 digest 和本地配置是否一致,如果一致,不做修改,否则标记 dirty,下一次心跳时将重建快照
4. - [x] 每隔一小段时间向 DnsServer 发送心跳和当前 digest
1. 如果没有 dirty 标记,心跳不含 snapshot
2. 如果有 dirty 标记,重建 snapshot 并在心跳中包含;
3. 如果 DnsServer 返回 resync,立刻重新发送带有 Snapshot 的心跳
5. - [x] 一个 RPC 接口,供 Peer 拉取 DNS 配置
6. - [x] 一个独立循环,用于选举 DnsServer,每次循环尝试绑定 DNS_SERVER_RPC_ADDR 监听 RPC 请求
1. 一台机器上所有 EasyTier 实例一起尝试绑定 DNS_SERVER_RPC_ADDR,绑定成功的那个就启动 DnsServer(当然也启动 DnsNode),失败的那些就只有 DnsNode
2. 每隔一小段时间或者 DnsNode 心跳失败(notify)后立刻尝试 bind,如果 bind 成功就说明 DnsServer 真挂了,那就自己在这个已有的 SocketAddr 上启动 DnsServer(忽略 bind 失败或启动失败,启动失败就直接释放 socket),这样才能保证服务不断
## DnsServer
1. - [x] 提供一个 RPC 接口接受 DnsNode 的心跳,如果心跳 digest 和本地不符则返回 resync
2. - [x] 收到含有 snapshot 的心跳时替换本地配置;如果 snapshot 中的 listeners 或者 addresses 不同则 rebind
3. - [x] (`moka::Cache`) 持续检查是否有过期(丢失心跳)的 DnsNode,需要把这些 DnsNode 提供的所有配置清除
4. - [x] 每次更新 zone 时自动添加 root zone
- [x] (`Zone::system`) 并把它的 forwarder 设置为系统 DNS
5. - [x] 使用 snapshot 更新 zone。不用合并同名 zone,直接用 Zone 结构体提供的 ChainedZoneHandler 按顺序插入 Catalog 就行,不过注意要先插入 MemoryZoneHandler,这些都是 records,后插入 ForwardZoneHandler,这都是 forwarders
6. - [x] 更新 zone 的时候自动去掉 forwarder 中导致回环的那些,就是把 addresses 和 listeners 去掉(root zone 也需要这个逻辑)
7. - [x] 内部接口,控制 DnsServer 是否 bind 到某些 socket(也就是配置中的 listeners
8. - [x] Listeners 绑定失败打印日志(失败一个打印一次然后就跳过),即便这时 addresses 为空也不要停机。(否则释放 socket 绑定后会有 instance 抢占 socket 试图启动 server,然后就死循环)
9. - [x] 内部接口,更新 addresses。目前这些用来 hijack 的 addresses 都是只支持 udp 简单查询,就是一个 UDP 包查询,tcp 完全不管。但是可以支持除了 53 之外的端口,这个不难。
- [x] 并且给 tun 添加删除这些 addresses 的路由
10. - [x] 启动时,往 packet pipeline 上挂一个 filter,和目前 magic dns 的操作一样,给 addresses 添加路由并劫持所有目的为配置中 addresses 的 UDP 包,直接作为 DNS request 读取并交给 DnsServer 解析
- [x] 这个 addresses 可能还得 append 到 resolv.conf 之类的地方
11. - [x] Addresses 和 Listeners 更新时~~需要检查所有 zone 的 forwarder~~直接更新所有 zone,之前为了避免回环可能去掉了一些 forwarder,或者有新的 forwarder 要去除
此外,还有以下几个设计要点:
- Zone 允许只有 forwarder,这时候就是纯转发器
- Zone 允许没有 forwarder,这时候要检查是不是有 SOA 和 NS 记录,如果没有可能需要添加?
- 另一种方案是 DnsNode 挂 filter,自己处理 UDP 劫持,用某种方式(如 RPC)把 DNS 请求代理给 DnsServer,该方案的优势在于完全解耦 DnsServer 的实现,特别是解决了 DnsServer 所在实例可能 no_tun 的问题,缺点是:
- 性能更差
- 操作路由表或 /etc/resolv.conf 时会有多个 instance 同时修改,修改结果没有确定性
- DnsServer 仍然需要得知 addresses 以进行回环检测
- debug 更麻烦
- 难以实现策略 DNS,比如不同来源的 DNS 请求走不同的 zone
另外任何关于系统 DNS 的操作,清理都参考现有的 magic dns。
## 已知但无需/无计划解决的问题
- the ttl option isn't working because of https://github.com/hickory-dns/hickory-dns/pull/3450
- [minor] address 路由绑定必须在有 tun 的实例上做;listener 绑定则与 tun 无关,现有竞选机制无法保证有 tun 的实例能优先启动 DnsServer
- 不妨假设大多数情况下一台机器上所有实例的 no_tun 设置相同,这时候这个问题实际上不存在
- [minor] DnsServer 更新 zone 的时候需要更精细的合并/去重控制,如延迟低者/本地优先
- [minor] 更新 forwarder 时还需要检查间接回环,如 DNS 请求发送给某个 Peer,这个 Peer 又把请求转发回自己了
- [minor] 防止死锁/挂起的 DnsServer 占用 socket
- ~~[minor] RoutePeerInfo 可能不能过大~~
- [minor] 增量 Zone 更新
- DNS 策略
</details>
<details>
<summary><h2>Related Issues</h2></summary>
- closes https://github.com/EasyTier/EasyTier/issues/742
- closes https://github.com/EasyTier/EasyTier/issues/771
- closes https://github.com/EasyTier/EasyTier/issues/927
- closes https://github.com/EasyTier/EasyTier/issues/1071
- closes https://github.com/EasyTier/EasyTier/issues/1142
- closes https://github.com/EasyTier/EasyTier/issues/1322
- closes https://github.com/EasyTier/EasyTier/issues/1381
- closes https://github.com/EasyTier/EasyTier/issues/1488
- closes https://github.com/EasyTier/EasyTier/issues/1597
- closes https://github.com/EasyTier/EasyTier/issues/1645
- closes https://github.com/EasyTier/EasyTier/issues/1764
- closes https://github.com/EasyTier/EasyTier/issues/1814
- closes https://github.com/EasyTier/EasyTier/issues/1826
- closes https://github.com/EasyTier/EasyTier/issues/2004
---
- (maybe) related to https://github.com/EasyTier/EasyTier/issues/937
- (maybe) related to https://github.com/EasyTier/EasyTier/issues/1016
- (maybe) related to https://github.com/EasyTier/EasyTier/issues/1348
- (maybe) related to https://github.com/EasyTier/EasyTier/issues/1699
- (maybe) related to https://github.com/EasyTier/EasyTier/issues/1873
</details>
+562
View File
@@ -0,0 +1,562 @@
# EasyTier DNS 模块设计说明(重构版)
> 本文档基于 `easytier/src/dns` 当前代码实现与 `plan.md`。
> 当前先给出第一部分:**整体架构与基础逻辑**。
## 1. 模块目标与定位
`dns` 模块是对旧 `instance/dns_server` 方案的重构,目标是把 DNS 能力从“单点功能”升级为“可同步、可扩展、可自治”的子系统。它同时承担三类职责:
1. **本机 DNS 服务能力**
- 能监听配置中的 `listeners`UDP/TCP)作为标准 DNS server。
- 能对配置中的 `addresses` 做流量劫持(UDP DNS + ICMP echo)。
2. **多 Peer DNS 配置同步能力**
- 每个实例作为 `DnsNode` 生成快照并定期心跳。
- 机器上被选举出的 `DnsServer` 聚合所有 Node 快照并动态重建 Catalog。
3. **系统 DNS 接入能力**tun 场景):
- 把 DNS nameserver/search/match domain 写入系统配置(当前主要是 Windows/macOSLinux 仍在演进中)。
---
## 2. 顶层架构(角色分层)
从职责上看,模块分成 4 层:
- **配置层**`config/*`
- 解析 TOML 的 `[dns]``[[dns.zone]]`、策略字段。
- 产出 `DnsConfig``ZoneConfig`,并提供默认值(如默认域名 `et.net`、默认地址 `100.100.100.101:53`)。
- **节点层(控制面)**`node.rs` + `peer_mgr.rs`
- `DnsNode`:本实例的 DNS 控制器,负责选举、心跳、事件监听、重建 snapshot。
- `DnsPeerMgr`:维护远端 peer 的 DNS 摘要与配置拉取,拼装 `DnsSnapshot`
- **服务层(数据面)**`server.rs` + `node_mgr.rs` + `zone.rs`
- `DnsServer`:真正处理 DNS 请求、维护监听 socket、管理 hijack addresses。
- `DnsNodeMgr`:服务端的快照管理器,接收 Node 心跳,维护节点 TTL 与 dirty 状态。
- `Zone`/`ZoneGroup`:把 records + forwarders 变成 Hickory `ZoneHandler` 并装配 `Catalog`
- **系统集成层**`system/*`
- 将当前 DNS 配置下发到 OS`SystemConfigurator` 抽象)。
- 服务退出/变更时负责清理或覆盖。
---
## 3. 关键对象与数据模型
- **`DnsConfig`**`config/dns.rs`
- 核心字段:`zones``policies``name``domain``addresses``listeners`
- `get_fqdn()``name + domain` 生成本机 FQDN。
- **`ZoneData` / `Zone`**`proto/dns.proto` + `zone.rs`
- `ZoneData` 是网络传输模型(protobuf),含 `id/origin/ttl/records/forwarders`
- `Zone` 是运行期模型:
- `records -> InMemoryZoneHandler`
- `forwarders -> ForwardZoneHandler`
- 同 origin 可链式共存(ChainedZoneHandler 语义)。
- **`DnsSnapshot`**`proto/dns.proto`
- Node 发给 Server 的完整状态:`zones + addresses + listeners`
- **`HeartbeatRequest`**
- 发送 `id + digest + optional snapshot`
- digest 一致时可只发轻量心跳,不带 snapshot。
- **`DirtyFlag`**`utils/dirty.rs`
- 全模块统一的“脏标记 + 通知器”,用于节流和增量触发(不是每次事件都全量重建)。
---
## 4. 基础运行逻辑(主链路)
### 4.1 本地节点启动
`Instance``magic-dns` feature 下创建并启动 `DnsNode``DnsNode` 启动后并行跑两个循环:
1. **选举循环**`run_election`
- 周期尝试绑定固定 RPC 地址 `tcp://127.0.0.1:49813`
- 绑定成功者成为本机 `DnsServer` 持有者;失败者继续只做 `DnsNode`
2. **主循环**`run`
- 监听配置变更/IP 变化/PeerInfo 更新。
- 维护 dirty 状态并按节奏发送 heartbeat。
### 4.2 快照构建与同步
`DnsPeerMgr::snapshot()` 组装快照:
- 本机 zones`dns_iter_zones()`(包含“自有专用 zone” + 用户配置 zone)。
- 远端 zones:从 peer RPC 拉取并缓存的 export zones。
- 本机 `addresses/listeners`:来自 `DnsConfig`
Node 发送 heartbeat 时:
- dirty 或首包 -> 带 `snapshot` 全量发送。
- 未 dirty -> 只发 `digest`(轻量心跳)。
- Server 返回 `resync=true` -> 立刻补发全量 snapshot。
### 4.3 服务端聚合与生效
`DnsNodeMgr` 收到 heartbeat 后:
- 若 snapshot digest 改变:更新节点缓存并标记 dirtycatalog/addresses/listeners 分开标记)。
- 若仅 digest 且本地无该节点或不一致:返回 `resync=true`
`DnsServer::run()` 有三个独立 reload 循环:
- `reload_catalog`:替换 `DynamicCatalog`
- `reload_addresses`:更新 hijack 地址,并尝试下发系统 DNS。
- `reload_listeners`:重绑 DNS listener socket。
这三个循环彼此解耦,避免单一失败阻塞全部 DNS 功能。
---
## 5. 数据面请求路径(DNS/ICMP 劫持)
`DnsServer` 作为 `NicPacketFilter` 挂入 packet pipeline
1. 检查目的 IP 是否命中 `addresses`
2. UDP
- 解析 DNS 请求 -> 投递给 `catalog.handle_request()`
- 用响应覆盖原 UDP payload,修正长度与校验和。
3. ICMP
- 对 EchoRequest 直接改写为 EchoReply。
4. 最后交换源/目的 IP,并把包回注到本机 peer pipeline。
这使得 `addresses` 不要求真实 bind/listen,也能作为“虚拟 DNS 入口地址”。
---
## 6. 可靠性与收敛机制
- **服务高可用(单机维度)**
- 任何实例都可竞选 Server;现任退出后其余实例会重试接管。
- **配置高效同步(全网维度)**
- `RoutePeerInfo` 只传播 DNS digest,不直接携带全量记录。
- digest 变化后才通过 RPC 拉取详情,降低路由泛洪压力。
- **自动过期清理**
- `DnsNodeMgr` 通过 `moka::Cache` TTL 自动淘汰失联节点配置(心跳过期)。
- **回环防护**
- 重建 zones 时会从 forwarders 中剔除本地 `addresses/listeners`,避免显式自环。
---
## 7. 当前实现状态(对应 plan.md)
从代码可见,以下主干能力已经落地:
- Node/Server 双角色、选举、心跳与 resync。
- 快照机制(zone/addresses/listeners)与 digest 驱动同步。
- 自有专用 zone 自动生成与 export。
- Catalog 动态替换、listener/address 分离热更新。
- UDP DNS 劫持 + ICMP 响应。
- forwarder 的本地回环剔除。
仍在计划中的重点:
- 系统 DNS 配置改造(尤其 Linux 路径统一与清理语义完善)。
- 更完整的单元测试覆盖与 CLI 状态输出。
---
## 8. 配置层详解(`config/*`
这一层负责把 TOML 配置映射成可校验、可传播、可计算 digest 的运行模型。
### 8.1 常量与默认值(`config/mod.rs`
- `DNS_DEFAULT_TLD = et.net.``domain` 缺省值。
- `DNS_DEFAULT_ADDRESS = udp://100.100.100.101:53``addresses` 缺省值。
- `DNS_SERVER_RPC_ADDR = tcp://127.0.0.1:49813`:本机 DNS Server 选举地址。
- `DNS_SERVER_ELECTION_INTERVAL = 5s`:选举重试周期。
- `DNS_SUPPORTED_PROTOCOLS = [Udp, Tcp]`:地址/转发器协议白名单。
### 8.2 `DnsConfig``config/dns.rs`
`DnsConfig``[dns]` 根配置,关键点如下:
- `zones: Vec<ZoneConfig>` 对应 `[[dns.zone]]`
- `policies: HashMap<LowerName, DnsPolicyConfig>``#[serde(flatten)]` 承接 `[dns."origin".import]` 形式策略。
- `name/domain` 组合 FQDN。
- `addresses/listeners` 使用 `NameServerAddrGroup`(支持 `ip``ip:port``udp://``tcp://` 解析)。
约束与语义:
- `deserialize_addresses()` 强制 `addresses` 只能是 UDP(与当前 hijack 数据面能力一致)。
- `get_name()`:若 `name` 为空,回退系统 hostname。
- `get_fqdn()`:将 `name` 拼接 `domain` 得到完整域名。
- `set_fqdn()`:反向拆分 FQDN 到 `name``domain`
### 8.3 `ZoneConfig` 与专用 Zone`config/zone.rs`
`ZoneConfig` 由两部分构成:
- `ZoneData`:用于 protobuf 传输(`id/origin/ttl/records/forwarders`)。
- `ZoneConfigInner`:配置层字段(含 policy)。
关键设计:
- `TryFrom<ZoneConfigInner> for ZoneConfig` 会立即调用 `Zone::try_from(&ZoneData)` 做语法校验,确保“能进配置就能进运行时”。
- `ZoneConfig::dedicated(...)` 用于自动生成“本节点专用 zone”:
- `origin = 节点 fqdn`
- records 自动填充 `@ IN A/AAAA ...`
- `policy.export = Some(default)`,默认可导出给 peers。
### 8.4 策略结构体现状(`config/policy.rs`
策略模型已就位,但功能并未完全落实到执行路径:
- `AclPolicy { whitelist, blacklist }`
- `FunctionalityPolicy { disabled }`
- `DnsPolicy { recursive }`
目前代码中的直接使用点主要是:
- `dns_export_config()` 只检查 `zone.policy.export.is_some()` 决定是否导出。
- `import/recursive/acl` 仍处于待完整落地状态(与 `plan.md` 的 TODO 对齐)。
### 8.5 `DnsGlobalCtxExt`:配置到发布面的桥(`config/dns.rs`
`GlobalCtx` 被扩展出 3 个关键方法:
- `dns_self_zone()`:基于当前 IP 与 FQDN 生成专用 zone。
- `dns_iter_zones()``self_zone + 用户配置 zones`
- `dns_export_config()`:从 `dns_iter_zones()` 中筛选可导出的 zones,并附加本机 `fqdn`
这三个方法是后续 `RoutePeerInfo.dns` digest 与 RPC 拉取的源头。
---
## 9. 节点控制面详解(`node.rs` + `peer_mgr.rs`
### 9.1 `DnsNode` 初始化与 RPC 注册
`DnsNode::new(...)` 会创建 `DnsPeerMgr`,并把 `DnsPeerMgrRpcServer` 注册到 peer RPC registry。
这使“我给别人提供 DNS 导出配置”与“我向别人拉取导出配置”在同一组件闭环。
### 9.2 选举循环(`DnsNode::run_election`
选举逻辑是“抢占固定地址”的单机 leader 机制:
1. 周期或被 `elect.notify_one()` 触发。
2. 尝试 `StandAloneServer(TcpTunnelListener(DNS_SERVER_RPC_ADDR)).serve()`
3. 绑定成功 -> 启动 `DnsServer`,注册 `DnsNodeMgrRpc`,并挂载 NIC packet pipeline。
4. `DnsServer` 退出后清理 pipeline,回到选举循环。
要点:
- 不依赖外部分布式锁,仅利用本机 socket 独占。
- 失败不是错误态,意味着“已有实例担任 Server”。
### 9.3 主循环(`DnsNode::run`
主循环负责“何时重建、何时发全量、何时触发重选举”:
- 维护 `HeartbeatRequest { id, digest, snapshot? }`
- 基于 `DirtyFlag` 动态调整心跳节奏:
- dirty 时更积极(`rr_interval`
- clean 时更快短轮询(`rr_interval / 8`
- 监听 `GlobalCtxEvent`
- `PeerInfoUpdated` -> 并发调用 `mgr.refresh(peer_id)`
- IP 变化、配置变化、事件丢失(lagged)-> `dirty.mark()`
- 心跳失败 -> 触发一次选举通知(可能是 Server 挂了)。
### 9.4 心跳协议(`DnsNode::heartbeat`
发送策略:
- 首次或 dirty -> `heartbeat.update(self.mgr.snapshot())`,发送全量 snapshot。
- 非 dirty -> 尽量只发 digest(轻量包)。
服务端响应:
- `resync = true` 时,客户端立刻再发一次带 snapshot 的心跳。
这实现了“正常轻量保活 + 状态漂移时快速自愈”。
### 9.5 `DnsPeerMgr`:远端配置拉取与去抖
`DnsPeerMgr` 核心职责:
- 本地缓存:`Cache<PeerId, DnsPeerInfo>`TTL = 3s)。
- `refresh(peer_id)`
- 先读路由里的 `route.dns` digest。
- 若与本地缓存一致则跳过 RPC。
- 不一致才调用 `fetch(peer_id)` 拉取 `GetExportConfigResponse`
- `snapshot()`:拼接
- 本机 zones`dns_iter_zones()`
- 所有远端缓存 zones
- 本机 addresses/listeners
这正是 `plan.md` 中“RoutePeerInfo 仅携带 hash,详情按需拉取”的落地实现。
---
## 10. 服务聚合与数据面详解(`node_mgr.rs` + `server.rs`
### 10.1 `DnsNodeMgr`:服务器侧状态机
`DnsNodeMgr` 保存每个 Node 的最新状态:
- `nodes: Cache<Uuid, DnsNodeInfo>`(TTL = 5s,心跳过期即自动淘汰)。
- `DnsNodeInfo = digest + zones + addresses + listeners`
- `dirty` 分三类:`catalog``addresses``listeners`
`heartbeat()` 判定逻辑:
- 请求带 snapshot
- 反序列化为 `DnsNodeInfo`
- digest 变化才更新缓存并打脏标记。
- 此分支返回 `resync = false`
- 请求不带 snapshot
- 若本地没有该 node 或 digest 不一致 -> `resync = true`
### 10.2 Catalog 构建(`DnsNodeMgr::catalog/collect_zones`
构建步骤:
1. 聚合全部节点 zones。
2. 追加 `Zone::system()` 作为 root zone。
3. 收集本地所有 `addresses + listeners` 形成 `local` 集合。
4. 遍历每个 zone 的 forwarders,剔除命中 `local` 的 nameserver(避免显式回环)。
5.`origin -> zone_handlers[]` 方式 `upsert` 到 Hickory `Catalog`
### 10.3 `DnsServer::run`:三路热重载
`DnsServer` 使用 3 个异步循环处理不同脏标记:
- `reload_catalog``DynamicCatalog::replace(...)` 原子替换目录。
- `reload_addresses`:更新劫持地址集合,并尝试下发系统 DNS。
- `reload_listeners`:重建 `ServerFuture` 的 UDP/TCP 监听 socket。
每路失败都会重新 `mark()` 自己,避免瞬时错误导致永久失效。
### 10.4 listener/address 的行为边界
- `listeners`:真正 bind 的服务地址;单个地址 bind 失败会打印错误并跳过,不导致整体停机。
- `addresses`:仅用于劫持匹配,不需要 bind;可用于 `no_tun=false` 下的虚拟 DNS 入口。
- `addresses``listeners` 分离,符合 `plan.md` 中“hijack 地址不等于监听地址”的设计。
### 10.5 NIC 数据面处理(`NicPacketFilter`
处理链:
1. `handle_ip_packet()` 解析 IPv4 头并检查目标 IP 是否属于 hijack 地址集合。
2. UDP 分支:
- `MessageRequest::from_bytes` 解包 DNS 查询。
- 交给 `catalog.handle_request(...)` 获取响应。
- 回填 payload,修正 UDP/IP 长度与 checksum。
3. ICMP 分支:
- EchoRequest 改写为 EchoReply。
4. 统一收尾:交换 src/dst IP,并把包路由回本机 `peer_id`
该路径让 DNS 响应无需经过用户态 socket recv/send,直接在 packet pipeline 内完成。
---
## 11. Zone 组装与权威链详解(`zone.rs`
### 11.1 `Zone` 运行时模型
`Zone` 包含:
- `id: Uuid`(来源于配置/网络数据)
- `origin: LowerName`
- `records: BTreeMap<RrKey, RecordSet>`
- `forward: Option<ForwardConfig>`
`PartialEq``forward` 使用自定义比较(只比较 nameserver 序列),避免与无关字段耦合。
### 11.2 反序列化与校验(`TryFrom<&ZoneData>`
转换过程:
1. 必须有 `id`,否则报错。
2. 用 Hickory `Parser` 解析 zone 文本(origin + RR)。
3.`forwarders` URL 转成 `NameServerAddr`,为空则 `forward=None`
这确保网络收到的 `ZoneData` 能直接映射成可执行 zone_handler。
### 11.3 ZoneHandler 构建策略
- `create_memory_zone_handler()`:仅当 records 非空时创建 `InMemoryZoneHandler`
- `create_forward_zone_handler()`:仅当 forward 非空时创建 `ForwardZoneHandler`
因此允许 3 种 zone 形态:
1. 纯记录(权威回答)
2. 纯转发(forward-only
3. 记录 + 转发(链式)
### 11.4 `ZoneGroup` 与同源链式行为
- `ZoneGroup::into_groups()``origin` 分组。
- `iter_zone_handlers()` 对每个 zone 按顺序产出:先 memory,再 forward。
- `DnsNodeMgr::catalog()` 把同 origin 的多个 zone zone_handler 以数组形式 `upsert`
结果是同 origin 下可自然形成 ChainedZoneHandler,不做“硬合并单 Zone”,与 `plan.md` 一致。
### 11.5 `Zone::system()` 的作用边界
`Zone::system()` 读取系统 resolver 作为 root zone forwarders。
在当前文档范围内仅关注它在 catalog 聚合中的语义:**兜底递归出口**。
---
## 12. 文档后续范围
后续若继续扩写,将集中在以下主题(不再展开 `system/*`):
1. 策略执行链路补齐:`import/recursive/acl` 如何从配置走到查询路径。
2. 测试矩阵梳理:单元测试、集成测试与故障注入测试的覆盖面。
3. CLI 状态输出:如何观测 node/server 角色、snapshot digest、zone 来源与健康状态。
---
## 13. 策略执行链路现状与缺口
本节专门回答一个容易误解的问题:**配置里有策略字段,不等于运行时已经完全执行**。
### 13.1 已生效的策略相关行为
当前代码中,和策略直接相关且已生效的路径主要有一条:
- `GlobalCtx::dns_export_config()` 在导出 zones 时仅检查:
- `zone.policy.export.is_some()`
也就是说,当前“导出/不导出”是可工作的,但粒度仍偏粗。
### 13.2 已建模但尚未完整落地的策略字段
以下字段在 `config/policy.rs` 已定义,但执行链路尚未完全打通:
- `import.whitelist / import.blacklist`
- `import.disabled`
- `import.recursive`
- `export` 内更细粒度 ACL
从调用路径看:
- `DnsPeerMgr::snapshot()` 只做本地 + 远端 zones 拼接,不做 import/export ACL 过滤。
- `DnsNodeMgr::collect_zones()` 只做聚合与回环剔除,不做来源级策略裁剪。
- `DnsServer::handle_ip_packet()` 是纯查询执行,不做请求来源与策略绑定。
### 13.3 代码中的明确信号(TODO)
当前有两个关键 TODO 信号:
- `dns_export_config()` 里标注了 `TODO: check policies of parent zones`
- `policy.rs``AclPolicy``recursive` 旁边保留了 TODO 注释。
这说明作者已经把策略模型前置到配置层,但执行面仍属于“进行中”。
### 13.4 文档使用建议(给维护者)
在策略彻底落地前,建议把语义按两层理解:
1. **已可依赖**`zone.policy.export.is_some()` 控制是否导出。
2. **暂不可依赖**import/export ACL、recursive、disabled 的全链路行为。
---
## 14. 测试体系与覆盖面
本模块测试不是集中在一个文件,而是“按组件就地内嵌”。
### 14.1 测试分布
- `dns/tests.rs`:测试基建与辅助函数(构造环境、启动 `DnsNode`、DNS 查询断言工具)。
- `dns/server.rs`:数据面与 server 行为主测试集。
- `dns/node_mgr.rs`:聚合 catalog 的基本可用性测试。
- `dns/zone.rs`:配置解析、记录转换、zone_handler 装配测试。
> 说明:`system/*` 也有测试,但本轮文档按约定不展开。
### 14.2 `server.rs` 覆盖要点
`server.rs` 的测试集中验证了以下核心行为:
- `DynamicCatalog::replace()` 可安全替换。
- hijack 判定:`is_hijacked_ip` / `is_hijacked_addr`
- ICMP 改写:EchoRequest -> EchoReply。
- UDP DNS 包内联处理:解析请求、生成应答、回填 payload。
- 一个基础端到端路径:真实 UDP listener + Hickory client 查询。
这些测试对应模块里最复杂、最容易回归的包处理逻辑。
### 14.3 `node_mgr.rs` 覆盖要点
`node_mgr.rs` 的测试重点是:
- 人工插入节点 zone 后,`catalog()` 能查到预期记录。
它验证了“快照聚合 -> Catalog 查询可用”的最小闭环,但尚未覆盖复杂心跳时序、TTL 过期后的清理行为。
### 14.4 `zone.rs` 覆盖要点
`zone.rs` 的测试覆盖了:
- TOML `DnsConfig` 解析。
- `ZoneConfig -> ZoneData -> Zone` 转换链。
- record 解析/TTL 基本行为。
- memory/forward zone_handler 构建,以及通过 server 查询验证。
该测试更多是“模型与解析正确性”,不是策略执行链路完整验证。
### 14.5 当前测试缺口
结合 `plan.md` 与现有测试,仍建议补充:
- `DnsNode` 心跳 + resync + 重选举的并发时序测试。
- `DnsNodeMgr` TTL 过期淘汰与脏标记联动测试。
- 策略字段(import/export ACL、recursive)的行为测试。
- 多 peer、同 origin 多 zone 的优先级/去重回归测试。
---
## 15. CLI 与可观测性现状
### 15.1 CLI 现状
从当前代码看,`easytier/src/easytier-cli.rs` 没有 DNS 专用子命令。
因此“查看 DNS 子系统状态”主要依赖日志与通用状态接口,而非专门 CLI 面板。
### 15.2 日志观测点(已存在)
`dns` 子系统已经布置了较多 `tracing` 埋点,关键入口包括:
- `DnsNode election loop`
- `DnsNode main loop`
- `DnsServer main loop`
- `DnsNodeMgr::heartbeat`(含来源 id 与 snapshot 信息)
可用于定位:
- 当前实例是否赢得选举。
- 心跳是否失败、是否触发 `resync`
- catalog/addresses/listeners 是否持续重载失败。
### 15.3 当前可观测性短板
- 缺少 DNS 专项 CLI 展示:
- 本机角色(Node/Server
- 当前 snapshot digest
- zone 来源与数量
- 监听地址与 hijack 地址状态
- 缺少结构化指标(metrics),目前偏日志驱动排障。
### 15.4 建议的最小可观测面
后续若补 CLI,可先实现一个最小 DNS 状态视图:
1. 角色与选举状态(是否持有 `DNS_SERVER_RPC_ADDR`)。
2. 最近心跳时间、`resync` 次数。
3. 已装载 zone 数量(按本地/远端分组)。
4. listeners 与 addresses 当前集合。
该视图不改变数据面行为,但能显著降低线上排障成本。
+570
View File
@@ -0,0 +1,570 @@
use crate::common::global_ctx::ArcGlobalCtx;
use crate::dns::node_mgr::DnsNodeMgr;
use crate::dns::system;
use crate::dns::utils::addr::NameServerAddr;
#[cfg(feature = "tun")]
use crate::instance::instance::{ArcNicCtx, NicCtx};
use crate::peers::peer_manager::PeerManager;
use crate::proto::dns::DnsNodeMgrRpcServer;
use crate::proto::rpc_impl::standalone::StandAloneServer;
use crate::tunnel::common::bind;
use crate::tunnel::tcp::TcpTunnelListener;
use crate::utils::task::CancellableTask;
use anyhow::Context;
use derivative::Derivative;
use guarden::guarded;
use hickory_net::runtime::Time;
use hickory_net::xfer::Protocol;
use hickory_server::{
Server,
server::{Request, RequestHandler, ResponseHandler, ResponseInfo},
zone_handler::Catalog,
};
use itertools::chain;
use parking_lot::RwLock;
use std::collections::HashSet;
use std::net::IpAddr;
use std::{sync::Arc, time::Duration};
use tokio::sync::Mutex;
use tokio_util::sync::CancellationToken;
use tracing::{Instrument, instrument};
#[derive(Clone)]
struct DynamicCatalog {
inner: Arc<tokio::sync::RwLock<Catalog>>,
}
impl DynamicCatalog {
fn new() -> Self {
Self {
inner: Arc::new(tokio::sync::RwLock::new(Catalog::new())),
}
}
async fn replace(&self, new: Catalog) {
*self.inner.write().await = new;
}
}
#[async_trait::async_trait]
impl RequestHandler for DynamicCatalog {
async fn handle_request<R: ResponseHandler, T: Time>(
&self,
request: &Request,
response_handle: R,
) -> ResponseInfo {
self.inner
.read()
.await
.handle_request::<_, T>(request, response_handle)
.await
}
}
#[derive(Derivative)]
#[derivative(Debug)]
pub struct DnsServer {
mgr: Arc<DnsNodeMgr>,
#[cfg(feature = "tun")]
nic_ctx: ArcNicCtx, // TODO: REMOVE THIS
peer_mgr: Arc<PeerManager>,
global_ctx: ArcGlobalCtx,
#[derivative(Debug = "ignore")]
catalog: DynamicCatalog,
runtime: Mutex<Option<CancellableTask<()>>>,
bindings: RwLock<HashSet<NameServerAddr>>,
addresses: RwLock<HashSet<NameServerAddr>>,
listeners: RwLock<HashSet<NameServerAddr>>,
}
const DNS_SERVER_TCP_TIMEOUT: Duration = Duration::from_secs(5);
const DNS_SERVER_TCP_BUFFER_SIZE: usize = 32;
impl DnsServer {
pub fn new(
peer_mgr: Arc<PeerManager>,
global_ctx: ArcGlobalCtx,
#[cfg(feature = "tun")] nic_ctx: ArcNicCtx, // TODO: REMOVE THIS
) -> Self {
Self {
mgr: Arc::new(DnsNodeMgr::new()),
#[cfg(feature = "tun")]
nic_ctx,
peer_mgr,
global_ctx,
catalog: DynamicCatalog::new(),
runtime: Default::default(),
bindings: Default::default(),
listeners: Default::default(),
addresses: Default::default(),
}
}
pub fn register(&self, rpc: &StandAloneServer<TcpTunnelListener>) {
rpc.registry()
.register(DnsNodeMgrRpcServer::new_arc(self.mgr.clone()), "");
}
#[cfg(feature = "tun")]
async fn update_system(&self, nameservers: &HashSet<NameServerAddr>) -> anyhow::Result<()> {
let nic_ctx = self.nic_ctx.lock().await;
let nic_ctx = nic_ctx
.as_ref()
.and_then(|nic_ctx| nic_ctx.downcast_ref::<NicCtx>())
.with_context(|| "failed to get NicCtx")?;
let ifname = nic_ctx
.ifname()
.await
.with_context(|| "failed to get interface name from NicCtx")?;
let system = system::get(&ifname)?.with_context(|| "failed to get system configurator")?;
let config = self.global_ctx.config.get_dns();
let domain = vec![config.domain.to_string()];
system.set_dns(&system::SystemConfig {
nameservers: nameservers
.iter()
.filter_map(|a| {
(a.protocol == Protocol::Udp && a.addr.port() == 53)
.then_some(a.addr.ip().to_string())
})
.collect(),
search_domains: domain.clone(),
match_domains: domain
.into_iter()
.chain(config.zones.iter().map(|z| z.origin.to_string()))
.collect(),
})?;
Ok(())
}
#[instrument(skip_all)]
async fn rebind(&self) -> anyhow::Result<bool> {
let Ok(mut runtime) = self.runtime.try_lock() else {
return Ok(false);
};
let mut bindings = {
let current = self.bindings.read();
let bindings = chain(
self.addresses.read().iter().cloned(),
self.listeners.read().iter().cloned(),
)
.collect();
if *current == bindings {
tracing::info!("bindings unchanged, no need to rebind");
return Ok(false);
}
bindings
};
if let Some(runtime) = runtime.take() {
runtime.stop(None).await?;
}
let mut server = Server::new(self.catalog.clone());
bindings.retain(|binding| {
let addr = binding.addr;
tracing::info!(?addr, "binding");
match binding.protocol {
Protocol::Tcp => bind().addr(addr).call().map(|s| {
server.register_listener(s, DNS_SERVER_TCP_TIMEOUT, DNS_SERVER_TCP_BUFFER_SIZE)
}),
Protocol::Udp => bind().addr(addr).call().map(|s| server.register_socket(s)),
_ => unimplemented!(),
}
.inspect_err(|error| tracing::error!(?addr, ?error, "failed to bind"))
.is_ok()
});
let token = server.shutdown_token().clone();
let handle = tokio::spawn(
async move {
server
.block_until_done()
.await
.unwrap_or_else(|e| tracing::error!("DNS server exited with error: {:?}", e));
}
.instrument(tracing::info_span!("DNS server backend runtime")),
);
*runtime = Some(CancellableTask::with_handle(token, handle));
#[cfg(feature = "tun")]
if let Err(error) = self.update_system(&bindings).await {
tracing::error!(?error, "failed to update system DNS settings");
}
*self.bindings.write() = bindings;
Ok(true)
}
#[instrument(skip_all)]
async fn reload_addresses(&self) -> anyhow::Result<()> {
let addresses = self.mgr.iter_addresses().collect();
let removed = {
let current = self.addresses.read();
if *current == addresses {
tracing::info!("addresses unchanged, no need to reload");
return Ok(());
}
current
.difference(&addresses)
.cloned()
.collect::<HashSet<_>>()
};
tracing::info!(?addresses, "reloading");
#[cfg(feature = "tun")]
{
let nic_ctx = self.nic_ctx.lock().await;
if let Some(nic_ctx) = nic_ctx
.as_ref()
.and_then(|nic_ctx| nic_ctx.downcast_ref::<NicCtx>())
{
for addr in &addresses {
let ip = addr.addr.ip();
if let Err(error) = match ip {
IpAddr::V4(ipv4) => nic_ctx.add_ipv4_to_tun_device(ipv4.into()).await,
IpAddr::V6(ipv6) => nic_ctx.add_ipv6_to_tun_device(ipv6.into()).await,
} {
tracing::error!(?addr, ?error, "failed to add address to tun device");
}
}
for addr in removed {
let ip = addr.addr.ip();
if let Err(error) = match ip {
IpAddr::V4(ipv4) => nic_ctx.remove_ipv4_from_tun_device(ipv4.into()).await,
IpAddr::V6(ipv6) => nic_ctx.remove_ipv6_from_tun_device(ipv6.into()).await,
} {
tracing::error!(?addr, ?error, "failed to remove address from tun device");
}
}
}
}
*self.addresses.write() = addresses;
self.rebind().await?;
Ok(())
}
#[instrument(skip_all)]
async fn reload_listeners(&self) -> anyhow::Result<()> {
let listeners = self.mgr.iter_listeners().collect();
if *self.listeners.read() == listeners {
tracing::info!("listeners unchanged, no need to reload");
return Ok(());
}
tracing::info!(?listeners, "reloading");
*self.listeners.write() = listeners;
self.rebind().await?;
Ok(())
}
#[instrument(skip_all, name = "DnsServer main loop")]
pub async fn run(&self, token: CancellationToken) {
let dirty = &self.mgr.dirty;
let runtime = None::<CancellableTask<()>>;
#[cfg(feature = "tun")]
guarded! {
system_guard => [
nic_ctx = self.nic_ctx.clone(),
]
async move {
if let Some(nic_ctx) = nic_ctx
.lock()
.await
.as_ref()
.and_then(|nic_ctx| nic_ctx.downcast_ref::<NicCtx>())
&& let Some(system) = nic_ctx
.ifname()
.await
.and_then(|ifname| system::get(&ifname).ok())
.flatten()
{
let _ = system.clean();
}
}
}
guarded! {
runtime_guard => [
mut runtime,
]
async move {
if let Some(runtime) = runtime.take() {
let _ = runtime.stop(Some(Duration::from_secs(1))).await;
}
}
}
let reload_catalog = async {
loop {
dirty.catalog.wait().await;
if dirty.catalog.reset() {
self.catalog.replace(self.mgr.catalog()).await;
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
};
let reload_addresses = async {
loop {
dirty.addresses.wait().await;
if dirty.addresses.reset()
&& let Err(error) = self.reload_addresses().await
{
tracing::error!(?error, "failed to reload addresses");
dirty.addresses.mark();
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
};
let reload_listeners = async {
loop {
dirty.listeners.wait().await;
if dirty.listeners.reset()
&& let Err(error) = self.reload_listeners().await
{
tracing::error!(?error, "failed to reload listeners");
dirty.listeners.mark();
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
};
tokio::select!(
_ = token.cancelled() => {
tracing::info!("DnsServer received shutdown signal, exiting server loop");
}
_ = reload_catalog => {},
_ = reload_addresses => {},
_ = reload_listeners => {},
);
#[cfg(feature = "tun")]
system_guard.trigger().await;
runtime_guard.trigger().await;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::peers::tests::create_mock_peer_manager;
use hickory_net::client::{Client, ClientHandle};
use hickory_net::runtime::TokioRuntimeProvider;
use hickory_net::udp::UdpClientStream;
use hickory_proto::op::{Message, MessageType, OpCode, Query};
use hickory_proto::rr::{DNSClass, Name, RData, Record, RecordType, rdata};
use hickory_proto::serialize::binary::BinEncodable;
use hickory_server::store::in_memory::InMemoryZoneHandler;
use hickory_server::zone_handler::ZoneType;
use hickory_server::zone_handler::{AxfrPolicy, Catalog};
use pnet::packet::icmp::{IcmpTypes, MutableIcmpPacket};
use pnet::packet::ipv4::MutableIpv4Packet;
use pnet::packet::udp::MutableUdpPacket;
use pnet::packet::{MutablePacket, icmp, ipv4, udp};
use std::net::Ipv4Addr;
use std::str::FromStr;
use std::time::Duration;
use tokio::time::sleep;
/// Build a `Catalog` containing a single A record: `test.example.com -> 1.2.3.4`.
fn build_test_catalog() -> Catalog {
let origin = Name::from_str("example.com.").unwrap();
let mut zone_handler = InMemoryZoneHandler::<TokioRuntimeProvider>::empty(
origin.clone(),
ZoneType::Primary,
AxfrPolicy::default(),
);
let record = Record::from_rdata(
Name::from_str("test.example.com.").unwrap(),
60,
RData::A(rdata::a::A(Ipv4Addr::new(1, 2, 3, 4))),
);
let rr_key =
hickory_proto::rr::RrKey::new(record.name.clone().into(), record.record_type());
let mut rr_set =
hickory_proto::rr::RecordSet::new(record.name.clone(), record.record_type(), 0);
rr_set.insert(record, 0);
zone_handler
.records_get_mut()
.insert(rr_key, Arc::new(rr_set));
let mut catalog = Catalog::new();
catalog.upsert(
origin.into(),
vec![Arc::new(zone_handler) as Arc<dyn hickory_server::zone_handler::ZoneHandler>],
);
catalog
}
/// Create a test `DnsServer` with `create_mock_peer_manager()`.
async fn create_test_server() -> Arc<DnsServer> {
let peer_mgr = create_mock_peer_manager().await;
let global_ctx = peer_mgr.get_global_ctx();
Arc::new(DnsServer::new(
peer_mgr,
global_ctx,
#[cfg(feature = "tun")]
ArcNicCtx::default(),
))
}
/// Build a raw IPv4 packet (as `Vec<u8>`) carrying the given L4 payload bytes.
/// `protocol` selects ICMP / UDP etc.
fn build_ipv4_packet(
src: Ipv4Addr,
dst: Ipv4Addr,
protocol: pnet::packet::ip::IpNextHeaderProtocol,
l4_payload: &[u8],
) -> Vec<u8> {
let ip_header_len = 20usize;
let total_len = ip_header_len + l4_payload.len();
let mut buf = vec![0u8; total_len];
{
let mut ip = MutableIpv4Packet::new(&mut buf).unwrap();
ip.set_version(4);
ip.set_header_length(5); // 20 bytes
ip.set_total_length(total_len as u16);
ip.set_ttl(64);
ip.set_next_level_protocol(protocol);
ip.set_source(src);
ip.set_destination(dst);
ip.payload_mut().copy_from_slice(l4_payload);
ip.set_checksum(ipv4::checksum(&ip.to_immutable()));
}
buf
}
/// Build ICMP Echo Request payload (8 bytes minimum).
fn build_icmp_echo_request() -> Vec<u8> {
let mut buf = vec![0u8; 8];
{
let mut icmp_pkt = MutableIcmpPacket::new(&mut buf).unwrap();
icmp_pkt.set_icmp_type(IcmpTypes::EchoRequest);
icmp_pkt.set_icmp_code(icmp::IcmpCode::new(0));
icmp_pkt.set_checksum(icmp::checksum(&icmp_pkt.to_immutable()));
}
buf
}
/// Build a minimal DNS query message for `name` and encode it to bytes.
fn build_dns_query_bytes(name: &str) -> Vec<u8> {
let mut msg = Message::new(0x1234, MessageType::Query, OpCode::Query);
msg.metadata.recursion_desired = true;
let mut query = Query::new();
query.set_name(Name::from_str(name).unwrap());
query.set_query_type(RecordType::A);
query.set_query_class(DNSClass::IN);
msg.add_query(query);
msg.to_bytes().unwrap().to_vec()
}
/// Build a UDP packet carrying `payload`, with given src/dst ports.
fn build_udp_packet(
src_port: u16,
dst_port: u16,
payload: &[u8],
src_ip: Ipv4Addr,
dst_ip: Ipv4Addr,
) -> Vec<u8> {
let udp_len = 8 + payload.len();
let mut buf = vec![0u8; udp_len];
{
let mut udp_pkt = MutableUdpPacket::new(&mut buf).unwrap();
udp_pkt.set_source(src_port);
udp_pkt.set_destination(dst_port);
udp_pkt.set_length(udp_len as u16);
udp_pkt.payload_mut().copy_from_slice(payload);
udp_pkt.set_checksum(udp::ipv4_checksum(
&udp_pkt.to_immutable(),
&src_ip,
&dst_ip,
));
}
buf
}
async fn wait_until(mut f: impl FnMut() -> bool) {
for _ in 0..80 {
if f() {
return;
}
sleep(Duration::from_millis(50)).await;
}
panic!("condition not met in time");
}
// ─── Tests ───────────────────────────────────────────────────────────
/// Full end-to-end test: start a real DNS UDP listener via `ServerFuture`,
/// send a query with a `hickory_client`, and verify the response.
#[tokio::test]
async fn should_resolve_record_via_real_udp_listener() {
use hickory_server::Server;
use tokio::net::UdpSocket;
use tokio::time::timeout;
// Build a catalog with test.example.com -> 1.2.3.4.
let catalog = build_test_catalog();
// Bind to a random port.
let socket = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let addr = socket.local_addr().unwrap();
let mut server = Server::new(catalog);
server.register_socket(socket);
let shutdown_token = server.shutdown_token().clone();
tokio::spawn(async move {
server.block_until_done().await.ok();
});
// Send a real DNS query using hickory_client.
let stream = UdpClientStream::builder(addr, TokioRuntimeProvider::default()).build();
let (mut client, bg) = Client::<TokioRuntimeProvider>::from_sender(stream);
tokio::spawn(bg);
let response = timeout(
Duration::from_secs(2),
client.query(
Name::from_str("test.example.com.").unwrap(),
DNSClass::IN,
RecordType::A,
),
)
.await
.expect("query timeout")
.expect("query failed");
assert!(!response.answers.is_empty(), "should get answers");
let a_record = &response.answers[0];
if let RData::A(a) = a_record.data {
assert_eq!(a.0, Ipv4Addr::new(1, 2, 3, 4));
} else {
panic!("expected A record, got {:?}", a_record.data);
}
// Shutdown the server.
shutdown_token.cancel();
}
}

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