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 YaoandGitHub 811f151155 refactor: rpc build (#2244)
rewrite rpc build with quota crate
2026-05-15 14:01:56 +08:00
Luna Yao 5e8a79a747 Cargo.lock 2026-05-14 02:37:34 +02:00
Luna Yao 8485091614 add RepeatedSerialize & RepeatedDeserialize 2026-05-14 02:37:16 +02:00
Luna Yao e31fee1a6d merge
Cargo.lock
2026-05-14 02:37:16 +02:00
Luna YaoandGitHub 743ec3c95e Merge branch 'main' into feat/dns-policy 2026-05-13 21:17:59 +02:00
Luna YaoandGitHub 8428a89d2d refactor: introduce HedgeExt for task hedging; rewrite NatDstQuicConnector (#2229) 2026-05-12 20:26:16 +08:00
513695297c [OHOS] feat: Enhance Rust kernel with config management and routing improvements (#2227)
* [OHOS.with ai] 将配置管理/配置分享/路由聚合/实例状态解析下沉至 Rust 内核,收敛职责并提升性能 (#2209)

* feat: add ohrs config store and startup error logging

* feat: full ability core for ohos

* feat: full ability core for ohos

* feat: clean code

---------

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

* fix: 添加缺失文件

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

* fix: rustfmt

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

* fix: 修复Option适配错误

* fix: rustfmt

* fix: rustfmt

---------

Co-authored-by: FrankHan <frankhan@FrankHans-Mac-mini.local>
2026-05-10 14:15:31 +08:00
21paradoxandGitHub bfbfa2ef8d fix: reuse conn by dst_peer_id, every peer use only 1 quic conn, to fix nat lost problem (#2216) 2026-05-09 22:33:44 +08:00
KKRainbowandGitHub 8e1d079142 feat: add Windows UDP broadcast relay (#2222)
This may helps games to find rooms in virtual network.

- add opt-in Windows UDP broadcast relay config flag and CLI/env plumbing
- capture local UDP broadcasts with Windows raw sockets, normalize packets, and inject them via PeerManager
2026-05-09 09:56:31 +08:00
fanyangandGitHub 55f15bb6f0 fix(connector): classify manual reconnect timeouts by stage (#2062) 2026-05-08 22:08:51 +08:00
Luna YaoandGitHub 96fd39649a revert UPX version to 4.2.4 in core.yml (#2221) 2026-05-07 18:49:40 +08:00
KKRainbowandGitHub 74fc8b300d chore: bump version to 2.6.4 (#2219) 2026-05-07 13:48:51 +08:00
KKRainbowandGitHub baeee40b79 fix machine uid and easytier-web panic (#2215)
1. fix(web-client): persist and migrate machine id
2. fix panic when easytier-web session receive malformat packet
2026-05-07 00:57:42 +08:00
fanyangandGitHub 4342c8d7a2 fix: add missing CLI help text (#2213) 2026-05-05 17:05:34 +08:00
KKRainbowandGitHub 1178b312fa fix foreign network entry leak (#2211) 2026-05-05 11:01:44 +08:00
Luna Yao 4c2859ae7c upgrade optionize to 0.2 2026-05-04 03:51:47 +02:00
Luna Yao c9e610a175 fmt 2026-05-04 02:06:50 +02:00
Luna Yao 22c458163a merge 2026-05-04 01:46:37 +02:00
Luna Yao ae83de91ab Merge branch 'main' into feat/dns-policy
# Conflicts:
#	easytier/build.rs
#	easytier/src/common/dns.rs
#	easytier/src/web_client/mod.rs
2026-05-04 01:44:54 +02:00
Luna Yao 348b83d768 use optionize 2026-05-04 01:33:57 +02:00
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
136 changed files with 14108 additions and 3619 deletions
+4 -1
View File
@@ -157,6 +157,9 @@ jobs:
- uses: mlugg/setup-zig@v2
if: ${{ contains(matrix.OS, 'ubuntu') }}
with:
version: 0.16.0
use-cache: true
- uses: taiki-e/install-action@v2
if: ${{ contains(matrix.OS, 'ubuntu') }}
@@ -227,7 +230,7 @@ jobs:
*) UPX_ARCH="amd64" ;;
esac
UPX_VERSION=5.1.1
UPX_VERSION=4.2.4
UPX_PKG="upx-${UPX_VERSION}-${UPX_ARCH}_linux"
curl -L "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/${UPX_PKG}.tar.xz" -s | tar xJvf -
cp "${UPX_PKG}/upx" .
+1 -1
View File
@@ -11,7 +11,7 @@ on:
image_tag:
description: 'Tag for this image build'
type: string
default: 'v2.6.3'
default: 'v2.6.4'
required: true
mark_latest:
description: 'Mark this image as latest'
+1 -1
View File
@@ -18,7 +18,7 @@ on:
version:
description: 'Version for this release'
type: string
default: 'v2.6.3'
default: 'v2.6.4'
required: true
make_latest:
description: 'Mark this release as latest'
Generated
+241 -165
View File
@@ -915,7 +915,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59"
dependencies = [
"once_cell",
"proc-macro-crate 3.2.0",
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -1981,37 +1981,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "derive_builder"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
dependencies = [
"derive_builder_macro",
]
[[package]]
name = "derive_builder_core"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
dependencies = [
"darling 0.20.10",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "derive_builder_macro"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
dependencies = [
"derive_builder_core",
"syn 2.0.117",
]
[[package]]
name = "derive_more"
version = "0.99.20"
@@ -2229,7 +2198,7 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "easytier"
version = "2.6.3"
version = "2.6.4"
dependencies = [
"aes-gcm",
"anyhow",
@@ -2262,35 +2231,36 @@ dependencies = [
"defguard_wireguard_rs",
"delegate",
"derivative",
"derive_builder",
"derive_more 2.1.1",
"easytier-rpc-build",
"encoding",
"flume 0.12.0",
"forwarded-header-value",
"futures",
"futures-util",
"gethostname 0.5.0",
"getset",
"git-version",
"globwalk",
"guarden",
"hickory-client",
"hickory-net",
"hickory-proto",
"hickory-resolver",
"hickory-server",
"hmac",
"hostname",
"http",
"http_req",
"humansize",
"humantime-serde",
"idna 1.0.3",
"igd-next",
"indexmap 2.14.0",
"indoc",
"itertools 0.14.0",
"kcp-sys",
"machine-uid",
"maplit",
"mimalloc",
"moka",
"multimap",
"natpmp",
"netlink-packet-core",
@@ -2301,6 +2271,7 @@ dependencies = [
"nix 0.29.0",
"once_cell",
"openssl",
"optionize",
"ordered_hash_map",
"parking_lot",
"paste",
@@ -2308,7 +2279,8 @@ dependencies = [
"petgraph 0.8.1",
"pin-project-lite",
"pnet",
"prefix-trie",
"prefix-trie 0.7.0",
"proc-macro2",
"prost",
"prost-build",
"prost-reflect",
@@ -2318,6 +2290,7 @@ dependencies = [
"prost-wkt-types",
"quinn",
"quinn-plaintext",
"quote",
"rand 0.8.5",
"rcgen",
"regex",
@@ -2330,6 +2303,7 @@ dependencies = [
"rustls",
"serde",
"serde_json",
"serde_with",
"serial_test",
"service-manager",
"sha2",
@@ -2357,7 +2331,6 @@ dependencies = [
"tokio-util",
"tokio-websockets",
"toml 0.8.19",
"tonic-build",
"tracing",
"tracing-subscriber",
"tun-easytier",
@@ -2384,7 +2357,7 @@ version = "0.1.0"
dependencies = [
"android_logger",
"easytier",
"jni",
"jni 0.21.1",
"log",
"once_cell",
"serde",
@@ -2405,7 +2378,7 @@ dependencies = [
[[package]]
name = "easytier-gui"
version = "2.6.3"
version = "2.6.4"
dependencies = [
"anyhow",
"async-trait",
@@ -2413,7 +2386,7 @@ dependencies = [
"dashmap",
"dunce",
"easytier",
"gethostname 1.1.0",
"gethostname",
"libc",
"once_cell",
"security-framework-sys",
@@ -2436,14 +2409,6 @@ dependencies = [
"windows 0.52.0",
]
[[package]]
name = "easytier-rpc-build"
version = "0.1.0"
dependencies = [
"heck 0.5.0",
"prost-build",
]
[[package]]
name = "easytier-uptime"
version = "0.1.0"
@@ -2486,7 +2451,7 @@ dependencies = [
[[package]]
name = "easytier-web"
version = "2.6.3"
version = "2.6.4"
dependencies = [
"anyhow",
"async-trait",
@@ -2708,24 +2673,6 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "endian-type"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d"
[[package]]
name = "enum-as-inner"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "enumflags2"
version = "0.7.12"
@@ -3307,16 +3254,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "gethostname"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc3655aa6818d65bc620d6911f05aa7b6aeb596291e1e9f79e52df85583d1e30"
dependencies = [
"rustix 0.38.34",
"windows-targets 0.52.6",
]
[[package]]
name = "gethostname"
version = "1.1.0"
@@ -3379,6 +3316,18 @@ dependencies = [
"wasip3",
]
[[package]]
name = "getset"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912"
dependencies = [
"proc-macro-error2",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "ghash"
version = "0.5.1"
@@ -3742,88 +3691,103 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hickory-client"
version = "0.25.2"
name = "hickory-net"
version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c466cd63a4217d5b2b8e32f23f58312741ce96e3c84bf7438677d2baff0fc555"
dependencies = [
"cfg-if",
"data-encoding",
"futures-channel",
"futures-util",
"hickory-proto",
"once_cell",
"radix_trie",
"rand 0.9.1",
"thiserror 2.0.11",
"tokio",
"tracing",
]
[[package]]
name = "hickory-proto"
version = "0.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502"
checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183"
dependencies = [
"async-trait",
"bytes",
"cfg-if",
"data-encoding",
"enum-as-inner",
"futures-channel",
"futures-io",
"futures-util",
"h2",
"hickory-proto",
"http",
"idna 1.0.3",
"ipnet",
"once_cell",
"rand 0.9.1",
"ring",
"jni 0.22.4",
"rand 0.10.1",
"rustls",
"serde",
"thiserror 2.0.11",
"tinyvec",
"tokio",
"tokio-rustls",
"tracing",
"url",
"webpki-roots 1.0.6",
]
[[package]]
name = "hickory-proto"
version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643"
dependencies = [
"data-encoding",
"idna 1.0.3",
"ipnet",
"jni 0.22.4",
"once_cell",
"prefix-trie 0.8.4",
"rand 0.10.1",
"ring",
"serde",
"thiserror 2.0.11",
"tinyvec",
"tracing",
"url",
]
[[package]]
name = "hickory-resolver"
version = "0.25.2"
version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a"
checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c"
dependencies = [
"cfg-if",
"futures-util",
"hickory-net",
"hickory-proto",
"ipconfig",
"ipnet",
"jni 0.22.4",
"moka",
"ndk-context",
"once_cell",
"parking_lot",
"rand 0.9.1",
"rand 0.10.1",
"resolv-conf",
"rustls",
"serde",
"smallvec",
"system-configuration 0.7.0",
"thiserror 2.0.11",
"tokio",
"tokio-rustls",
"tracing",
"webpki-roots 1.0.6",
]
[[package]]
name = "hickory-server"
version = "0.25.2"
version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d53e5fe811b941c74ee46b8818228bfd2bc2688ba276a0eaeb0f2c95ea3b2585"
checksum = "130236ba6abba90da6a7acf7a87b27d862b592c3145dc74bc47bf86d8ff198ec"
dependencies = [
"async-trait",
"bytes",
"cfg-if",
"data-encoding",
"enum-as-inner",
"futures-util",
"hickory-net",
"hickory-proto",
"hickory-resolver",
"ipnet",
"prefix-trie",
"prefix-trie 0.8.4",
"serde",
"thiserror 2.0.11",
"time",
@@ -3859,6 +3823,17 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "hostname"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd"
dependencies = [
"cfg-if",
"libc",
"windows-link 0.2.1",
]
[[package]]
name = "html5ever"
version = "0.29.1"
@@ -4044,7 +4019,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.5.10",
"socket2 0.6.3",
"tokio",
"tower-service",
"tracing",
@@ -4556,19 +4531,68 @@ dependencies = [
"cesu8",
"cfg-if",
"combine",
"jni-sys",
"jni-sys 0.3.0",
"log",
"thiserror 1.0.63",
"walkdir",
"windows-sys 0.45.0",
]
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys 0.4.1",
"log",
"simd_cesu8",
"thiserror 2.0.11",
"walkdir",
"windows-link 0.2.1",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn 2.0.117",
]
[[package]]
name = "jni-sys"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
[[package]]
name = "jni-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
dependencies = [
"jni-sys-macros",
]
[[package]]
name = "jni-sys-macros"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
"syn 2.0.117",
]
[[package]]
name = "jobserver"
version = "0.1.32"
@@ -5103,9 +5127,12 @@ 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",
@@ -5207,7 +5234,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
dependencies = [
"bitflags 2.8.0",
"jni-sys",
"jni-sys 0.3.0",
"log",
"ndk-sys",
"num_enum",
@@ -5227,7 +5254,7 @@ version = "0.6.0+11769913"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
dependencies = [
"jni-sys",
"jni-sys 0.3.0",
]
[[package]]
@@ -5243,7 +5270,7 @@ dependencies = [
"netlink-packet-route 0.17.1",
"netlink-sys",
"once_cell",
"system-configuration",
"system-configuration 0.6.1",
"windows-sys 0.52.0",
]
@@ -5354,15 +5381,6 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nibble_vec"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43"
dependencies = [
"smallvec",
]
[[package]]
name = "nix"
version = "0.25.1"
@@ -5581,7 +5599,7 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
dependencies = [
"proc-macro-crate 3.2.0",
"proc-macro-crate 2.0.0",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -5932,6 +5950,30 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "optionize"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f15b218321873a7919c8b2a188ad488bf18c067478274be4c69c3d402b2be3ea"
dependencies = [
"delegate",
"derive_more 2.1.1",
"optionize-macros",
]
[[package]]
name = "optionize-macros"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1af5a2aba73a1808cbb39b451aabb5f8dc5c9c73d68ce5e61b79f8892f775a0f"
dependencies = [
"darling 0.23.0",
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "ordered-float"
version = "2.10.1"
@@ -6661,6 +6703,17 @@ dependencies = [
"num-traits",
]
[[package]]
name = "prefix-trie"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7"
dependencies = [
"either",
"ipnet",
"num-traits",
]
[[package]]
name = "prettyplease"
version = "0.2.20"
@@ -6701,11 +6754,11 @@ dependencies = [
[[package]]
name = "proc-macro-crate"
version = "3.2.0"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit 0.22.20",
"toml_edit 0.25.12+spec-1.1.0",
]
[[package]]
@@ -7058,16 +7111,6 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
[[package]]
name = "radix_trie"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd"
dependencies = [
"endian-type",
"nibble_vec",
]
[[package]]
name = "rand"
version = "0.7.3"
@@ -7394,7 +7437,7 @@ dependencies = [
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"system-configuration",
"system-configuration 0.6.1",
"tokio",
"tokio-native-tls",
"tokio-rustls",
@@ -7569,7 +7612,7 @@ checksum = "1f168d99749d307be9de54d23fd226628d99768225ef08f6ffb52e0182a27746"
dependencies = [
"cfg-if",
"glob",
"proc-macro-crate 3.2.0",
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"regex",
@@ -7732,6 +7775,7 @@ version = "0.23.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "730944ca083c1c233a75c09f199e973ca499344a2b7ba9e755c457e86fb4a321"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
@@ -7778,7 +7822,7 @@ checksum = "19787cda76408ec5404443dc8b31795c87cd8fec49762dc75fa727740d34acc1"
dependencies = [
"core-foundation 0.10.0",
"core-foundation-sys",
"jni",
"jni 0.21.1",
"log",
"once_cell",
"rustls",
@@ -8577,6 +8621,16 @@ version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe"
[[package]]
name = "simd_cesu8"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33"
dependencies = [
"rustc_version",
"simdutf8",
]
[[package]]
name = "simdutf8"
version = "0.1.5"
@@ -9136,6 +9190,17 @@ dependencies = [
"system-configuration-sys",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.8.0",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
@@ -9205,7 +9270,7 @@ dependencies = [
"gdkwayland-sys",
"gdkx11-sys",
"gtk",
"jni",
"jni 0.21.1",
"libc",
"log",
"ndk",
@@ -9267,7 +9332,7 @@ dependencies = [
"heck 0.5.0",
"http",
"image 0.25.10",
"jni",
"jni 0.21.1",
"libc",
"log",
"mime",
@@ -9402,7 +9467,7 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8f08346c8deb39e96f86973da0e2d76cbb933d7ac9b750f6dc4daf955a6f997"
dependencies = [
"gethostname 1.1.0",
"gethostname",
"log",
"os_info",
"serde",
@@ -9495,7 +9560,7 @@ dependencies = [
"dpi",
"gtk",
"http",
"jni",
"jni 0.21.1",
"objc2",
"objc2-ui-kit",
"objc2-web-kit",
@@ -9518,7 +9583,7 @@ checksum = "e11ea2e6f801d275fdd890d6c9603736012742a1c33b96d0db788c9cdebf7f9e"
dependencies = [
"gtk",
"http",
"jni",
"jni 0.21.1",
"log",
"objc2",
"objc2-app-kit",
@@ -9969,6 +10034,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.19.15"
@@ -10006,6 +10080,18 @@ dependencies = [
"winnow 0.6.18",
]
[[package]]
name = "toml_edit"
version = "0.25.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
dependencies = [
"indexmap 2.14.0",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"winnow 1.0.1",
]
[[package]]
name = "toml_parser"
version = "1.1.2+spec-1.1.0"
@@ -10051,19 +10137,6 @@ dependencies = [
"tracing",
]
[[package]]
name = "tonic-build"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "568392c5a2bd0020723e3f387891176aabafe36fd9fcd074ad309dfa0c8eb964"
dependencies = [
"prettyplease",
"proc-macro2",
"prost-build",
"quote",
"syn 2.0.117",
]
[[package]]
name = "tower"
version = "0.4.13"
@@ -11866,6 +11939,9 @@ name = "winnow"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5"
dependencies = [
"memchr",
]
[[package]]
name = "winreg"
@@ -12067,7 +12143,7 @@ dependencies = [
"html5ever",
"http",
"javascriptcore-rs",
"jni",
"jni 0.21.1",
"kuchikiki",
"libc",
"ndk",
@@ -12130,7 +12206,7 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
dependencies = [
"gethostname 1.1.0",
"gethostname",
"rustix 1.0.7",
"x11rb-protocol",
]
@@ -12247,7 +12323,7 @@ version = "5.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222"
dependencies = [
"proc-macro-crate 3.2.0",
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -12484,7 +12560,7 @@ version = "5.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c"
dependencies = [
"proc-macro-crate 3.2.0",
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
-1
View File
@@ -3,7 +3,6 @@ resolver = "2"
members = [
"easytier",
"easytier-gui/src-tauri",
"easytier-rpc-build",
"easytier-web",
"easytier-contrib/easytier-ffi",
"easytier-contrib/easytier-uptime",
+1 -1
View File
@@ -1,6 +1,6 @@
id=easytier_magisk
name=EasyTier_Magisk
version=v2.6.3
version=v2.6.4
versionCode=1
author=EasyTier
description=easytier magisk module @EasyTier(https://github.com/EasyTier/EasyTier)
+544 -132
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -7,6 +7,10 @@ edition = "2024"
crate-type=["cdylib"]
[dependencies]
async-trait = "0.1"
base64 = "0.22"
flate2 = "1.1"
gethostname = "1.1"
ohos-hilog-binding = {version = "*", features = ["redirect"]}
easytier = { path = "../../easytier" }
napi-derive-ohos = "1.1"
@@ -26,10 +30,16 @@ napi-ohos = { version = "1.1", default-features = false, features = [
"web_stream",
] }
once_cell = "1.21.3"
ipnet = "2.10"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.125"
prost-reflect = { version = "0.14.5", default-features = false, features = ["derive"] }
rusqlite = { version = "0.32", features = ["bundled"] }
tracing-subscriber = "0.3.19"
tracing-core = "0.1.33"
tracing = "0.1.41"
tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] }
url = "2.5"
uuid = { version = "1.5.0", features = [
"v4",
"fast-rng",
@@ -0,0 +1,4 @@
pub(crate) mod repository;
pub(crate) mod services;
pub(crate) mod storage;
pub(crate) mod types;
@@ -0,0 +1,13 @@
#[path = "../../config_repo/field_store.rs"]
mod field_store;
#[path = "../../config_repo/import_export.rs"]
mod import_export;
#[path = "../../config_repo/legacy_migration.rs"]
mod legacy_migration;
#[path = "../../config_repo/validation.rs"]
mod validation;
#[path = "../../config_repo.rs"]
mod repo;
pub use repo::*;
@@ -0,0 +1,2 @@
pub(crate) mod schema_service;
pub(crate) mod share_link_service;
@@ -0,0 +1,414 @@
use easytier::proto::ALL_DESCRIPTOR_BYTES;
use napi_derive_ohos::napi;
use once_cell::sync::Lazy;
use prost_reflect::{Cardinality, DescriptorPool, FieldDescriptor, Kind, MessageDescriptor};
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
#[napi(object)]
pub struct FieldOption {
pub label: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize)]
#[napi(object)]
pub struct ValidationRule {
pub rule_type: String,
pub arg: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize)]
#[napi(object)]
pub struct NetworkConfigSchema {
pub node_kind: String,
pub name: String,
pub field_number: i32,
pub type_name: Option<String>,
pub semantic_type: Option<String>,
pub value_kind: String,
pub is_list: bool,
pub required: bool,
pub default_value_text: Option<String>,
pub enum_options: Vec<FieldOption>,
pub validations: Vec<ValidationRule>,
pub children: Vec<NetworkConfigSchema>,
pub definitions: Vec<NetworkConfigSchema>,
}
#[derive(Debug, Clone, Serialize)]
#[napi(object)]
pub struct ConfigFieldMapping {
pub field_name: String,
pub field_number: i32,
}
static DESCRIPTOR_POOL: Lazy<DescriptorPool> = Lazy::new(|| {
DescriptorPool::decode(ALL_DESCRIPTOR_BYTES)
.expect("easytier descriptor pool should decode from embedded protobuf descriptors")
});
const NETWORK_CONFIG_MESSAGE_NAME: &str = "api.manage.NetworkConfig";
fn descriptor_pool() -> &'static DescriptorPool {
&DESCRIPTOR_POOL
}
fn network_config_descriptor() -> MessageDescriptor {
descriptor_pool()
.get_message_by_name(NETWORK_CONFIG_MESSAGE_NAME)
.expect("api.manage.NetworkConfig descriptor should exist")
}
fn field_default_value_text(field: &FieldDescriptor) -> Option<String> {
if field.is_list() || field.is_map() {
return Some("[]".to_string());
}
match field.kind() {
Kind::Bool => Some("false".to_string()),
Kind::String => Some("\"\"".to_string()),
Kind::Bytes => Some("\"\"".to_string()),
Kind::Int32
| Kind::Sint32
| Kind::Sfixed32
| Kind::Int64
| Kind::Sint64
| Kind::Sfixed64
| Kind::Uint32
| Kind::Fixed32
| Kind::Uint64
| Kind::Fixed64
| Kind::Float
| Kind::Double => Some("0".to_string()),
Kind::Enum(enum_desc) => enum_desc
.get_value(0)
.map(|value| value.number().to_string()),
Kind::Message(_) => None,
}
}
fn field_type_name(field: &FieldDescriptor) -> Option<String> {
match field.kind() {
Kind::Enum(enum_desc) => Some(enum_desc.full_name().to_string()),
Kind::Message(message_desc) => Some(message_desc.full_name().to_string()),
_ => None,
}
}
fn field_semantic_type(field: &FieldDescriptor) -> Option<String> {
match field.name() {
"virtual_ipv4" => Some("cidr_ip".to_string()),
"network_length" => Some("cidr_mask".to_string()),
"peer_urls" => Some("peer[]".to_string()),
"proxy_cidrs" => Some("cidr[]".to_string()),
"listener_urls" => Some("listener[]".to_string()),
"routes" => Some("route[]".to_string()),
"exit_nodes" => Some("ip[]".to_string()),
"relay_network_whitelist" => Some("network_name[]".to_string()),
"mapped_listeners" => Some("mapped_listener[]".to_string()),
"port_forwards" => Some("port_forward[]".to_string()),
_ => None,
}
}
fn enum_options(kind: Kind) -> Vec<FieldOption> {
match kind {
Kind::Enum(enum_desc) => enum_desc
.values()
.map(|value| FieldOption {
label: value.name().to_string(),
value: value.number().to_string(),
})
.collect(),
_ => Vec::new(),
}
}
fn should_expose_field(field: &FieldDescriptor) -> bool {
match field.containing_oneof() {
Some(_) => field
.field_descriptor_proto()
.proto3_optional
.unwrap_or(false),
None => true,
}
}
fn build_validations(field: &FieldDescriptor) -> Vec<ValidationRule> {
if field.cardinality() == Cardinality::Required {
return vec![ValidationRule {
rule_type: "required".to_string(),
arg: String::new(),
message: format!("{} is required", field.name()),
}];
}
Vec::new()
}
fn kind_to_value_kind(field: &FieldDescriptor) -> String {
if field.is_map() {
return "object".to_string();
}
match field.kind() {
Kind::Bool => "boolean".to_string(),
Kind::String | Kind::Bytes => "string".to_string(),
Kind::Int32
| Kind::Sint32
| Kind::Sfixed32
| Kind::Int64
| Kind::Sint64
| Kind::Sfixed64
| Kind::Uint32
| Kind::Fixed32
| Kind::Uint64
| Kind::Fixed64
| Kind::Float
| Kind::Double => "number".to_string(),
Kind::Enum(_) => "enum".to_string(),
Kind::Message(_) => "object".to_string(),
}
}
fn build_node(
node_kind: &str,
name: String,
field_number: i32,
type_name: Option<String>,
semantic_type: Option<String>,
value_kind: String,
is_list: bool,
required: bool,
default_value_text: Option<String>,
enum_options: Vec<FieldOption>,
validations: Vec<ValidationRule>,
children: Vec<NetworkConfigSchema>,
definitions: Vec<NetworkConfigSchema>,
) -> NetworkConfigSchema {
NetworkConfigSchema {
node_kind: node_kind.to_string(),
name,
field_number,
type_name,
semantic_type,
value_kind,
is_list,
required,
default_value_text,
enum_options,
validations,
children,
definitions,
}
}
fn build_map_entry_node(message_desc: &MessageDescriptor) -> NetworkConfigSchema {
let key_field = message_desc.map_entry_key_field();
let value_field = message_desc.map_entry_value_field();
build_node(
"object",
message_desc.name().to_string(),
0,
Some(message_desc.full_name().to_string()),
None,
"object".to_string(),
false,
true,
None,
Vec::new(),
Vec::new(),
vec![
build_schema_field_node(&key_field),
build_schema_field_node(&value_field),
],
Vec::new(),
)
}
fn field_children(field: &FieldDescriptor) -> Vec<NetworkConfigSchema> {
if field.is_map() {
if let Kind::Message(message_desc) = field.kind() {
return vec![build_map_entry_node(&message_desc)];
}
}
match field.kind() {
Kind::Message(message_desc) => build_message_children(&message_desc),
_ => Vec::new(),
}
}
fn build_message_children(message_desc: &MessageDescriptor) -> Vec<NetworkConfigSchema> {
message_desc
.fields()
.filter(should_expose_field)
.map(|field| build_schema_field_node(&field))
.collect()
}
fn build_schema_field_node(field: &FieldDescriptor) -> NetworkConfigSchema {
build_node(
"field",
field.name().to_string(),
field.number() as i32,
field_type_name(field),
field_semantic_type(field),
kind_to_value_kind(field),
field.is_list() || field.is_map(),
field.cardinality() == Cardinality::Required,
field_default_value_text(field),
enum_options(field.kind()),
build_validations(field),
field_children(field),
Vec::new(),
)
}
fn collect_definitions() -> Vec<NetworkConfigSchema> {
let mut definitions = Vec::new();
for message_desc in descriptor_pool().all_messages() {
let full_name = message_desc.full_name();
if full_name == NETWORK_CONFIG_MESSAGE_NAME || message_desc.is_map_entry() {
continue;
}
definitions.push(build_node(
"object",
full_name.to_string(),
0,
Some(full_name.to_string()),
None,
"object".to_string(),
false,
true,
None,
Vec::new(),
Vec::new(),
build_message_children(&message_desc),
Vec::new(),
));
}
for enum_desc in descriptor_pool().all_enums() {
definitions.push(build_node(
"enum",
enum_desc.full_name().to_string(),
0,
Some(enum_desc.full_name().to_string()),
None,
"enum".to_string(),
false,
false,
None,
enum_options(Kind::Enum(enum_desc.clone())),
Vec::new(),
Vec::new(),
Vec::new(),
));
}
definitions.sort_by(|a, b| a.name.cmp(&b.name));
definitions
}
fn build_network_config_schema() -> NetworkConfigSchema {
let network_config = network_config_descriptor();
build_node(
"schema",
network_config.name().to_string(),
0,
Some(network_config.full_name().to_string()),
None,
"object".to_string(),
false,
true,
None,
Vec::new(),
Vec::new(),
build_message_children(&network_config),
collect_definitions(),
)
}
fn build_network_config_field_mappings() -> Vec<ConfigFieldMapping> {
network_config_descriptor()
.fields()
.filter(should_expose_field)
.map(|field| ConfigFieldMapping {
field_name: field.name().to_string(),
field_number: field.number() as i32,
})
.collect()
}
pub fn get_network_config_schema() -> NetworkConfigSchema {
build_network_config_schema()
}
pub fn get_network_config_field_mappings() -> Vec<ConfigFieldMapping> {
build_network_config_field_mappings()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schema_is_exposed_as_single_tree_type() {
let schema = get_network_config_schema();
assert_eq!(schema.node_kind, "schema");
assert_eq!(schema.name, "NetworkConfig");
assert_eq!(
schema.type_name.as_deref(),
Some("api.manage.NetworkConfig")
);
let virtual_ipv4 = schema
.children
.iter()
.find(|field| field.name == "virtual_ipv4")
.expect("virtual_ipv4 field");
assert_eq!(virtual_ipv4.semantic_type.as_deref(), Some("cidr_ip"));
let secure_mode = schema
.children
.iter()
.find(|field| field.name == "secure_mode")
.expect("secure_mode field");
assert!(
secure_mode
.children
.iter()
.any(|field| field.name == "enabled")
);
let secure_mode_definition = schema
.definitions
.iter()
.find(|definition| definition.name == "common.SecureModeConfig")
.expect("secure mode definition");
assert!(
secure_mode_definition
.children
.iter()
.any(|field| field.name == "local_private_key")
);
let networking_method_definition = schema
.definitions
.iter()
.find(|definition| definition.name == "api.manage.NetworkingMethod")
.expect("networking method enum definition");
assert!(
networking_method_definition
.enum_options
.iter()
.any(|option| option.label == "PublicServer")
);
}
}
@@ -0,0 +1,197 @@
use crate::config::repository::{get_config_record, save_config_record};
use crate::config::services::schema_service::get_network_config_field_mappings;
use crate::config::types::stored_config::SharedConfigLinkPayload;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use easytier::proto::api::manage::NetworkConfig;
use flate2::{Compression, read::ZlibDecoder, write::ZlibEncoder};
use gethostname::gethostname;
use std::collections::HashMap;
use std::io::{Read, Write};
use url::Url;
use uuid::Uuid;
const SHARE_LINK_HOST: &str = "easytier.cn";
const SHARE_LINK_PATH: &str = "/comp_cfg";
fn field_name_to_id_map() -> HashMap<String, String> {
get_network_config_field_mappings()
.into_iter()
.map(|mapping| (mapping.field_name, mapping.field_number.to_string()))
.collect()
}
fn field_id_to_name_map() -> HashMap<String, String> {
get_network_config_field_mappings()
.into_iter()
.map(|mapping| (mapping.field_number.to_string(), mapping.field_name))
.collect()
}
fn prune_empty(value: &serde_json::Value) -> Option<serde_json::Value> {
match value {
serde_json::Value::Null => None,
serde_json::Value::Array(values) if values.is_empty() => None,
_ => Some(value.clone()),
}
}
fn map_config_json(config: &NetworkConfig) -> Result<String, String> {
let field_name_to_id = field_name_to_id_map();
let raw = serde_json::to_value(config).map_err(|err| err.to_string())?;
let mut mapped = serde_json::Map::new();
for (key, value) in raw.as_object().cloned().unwrap_or_default() {
let Some(value) = prune_empty(&value) else {
continue;
};
let mapped_key = field_name_to_id.get(&key).cloned().unwrap_or(key);
mapped.insert(mapped_key, value);
}
serde_json::to_string(&mapped).map_err(|err| err.to_string())
}
fn unmap_config_json(raw: &str) -> Result<NetworkConfig, String> {
let field_id_to_name = field_id_to_name_map();
let value = serde_json::from_str::<serde_json::Value>(raw).map_err(|err| err.to_string())?;
let mut mapped = serde_json::Map::new();
for (key, value) in value.as_object().cloned().unwrap_or_default() {
let field_name = field_id_to_name.get(&key).cloned().unwrap_or(key);
mapped.insert(field_name, value);
}
serde_json::from_value(serde_json::Value::Object(mapped)).map_err(|err| err.to_string())
}
fn compress_to_base64url(raw: &str) -> Result<String, String> {
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::best());
encoder
.write_all(raw.as_bytes())
.map_err(|err| err.to_string())?;
let compressed = encoder.finish().map_err(|err| err.to_string())?;
Ok(URL_SAFE_NO_PAD.encode(compressed))
}
fn decompress_from_base64url(raw: &str) -> Result<String, String> {
let compressed = URL_SAFE_NO_PAD.decode(raw).map_err(|err| err.to_string())?;
let mut decoder = ZlibDecoder::new(compressed.as_slice());
let mut out = String::new();
decoder
.read_to_string(&mut out)
.map_err(|err| err.to_string())?;
Ok(out)
}
pub fn build_config_share_link(
config_id: &str,
display_name: Option<String>,
only_start: bool,
) -> Option<String> {
let record = get_config_record(config_id)?;
let config = serde_json::from_str::<NetworkConfig>(&record.config_json).ok()?;
let mapped_json = map_config_json(&config).ok()?;
let compressed = compress_to_base64url(&mapped_json).ok()?;
let final_name = display_name
.or(Some(record.meta.display_name))
.filter(|name| !name.is_empty());
let mut url = Url::parse(&format!("https://{SHARE_LINK_HOST}{SHARE_LINK_PATH}")).ok()?;
url.query_pairs_mut().append_pair("cfg", &compressed);
if let Some(name) = final_name {
url.query_pairs_mut().append_pair("name", &name);
}
if only_start {
url.query_pairs_mut().append_pair("only_start", "true");
}
Some(url.to_string())
}
pub fn parse_config_share_link(share_link: &str) -> Option<SharedConfigLinkPayload> {
let url = Url::parse(share_link).ok()?;
if url.host_str()? != SHARE_LINK_HOST || url.path() != SHARE_LINK_PATH {
return None;
}
let cfg = url
.query_pairs()
.find(|(key, _)| key == "cfg")?
.1
.to_string();
let mapped_json = decompress_from_base64url(&cfg).ok()?;
let mut config = unmap_config_json(&mapped_json).ok()?;
config.instance_id = Some(Uuid::new_v4().to_string());
let hostname = gethostname().to_string_lossy().to_string();
if !hostname.is_empty() {
config.hostname = Some(hostname);
}
let config_json = serde_json::to_string(&config).ok()?;
let display_name = url
.query_pairs()
.find(|(key, _)| key == "name")
.map(|(_, value)| value.to_string())
.filter(|name| !name.is_empty());
let only_start = url
.query_pairs()
.find(|(key, _)| key == "only_start")
.map(|(_, value)| value == "true")
.unwrap_or(false);
Some(SharedConfigLinkPayload {
config_json,
display_name,
only_start,
})
}
pub fn import_config_share_link(
share_link: &str,
display_name_override: Option<String>,
) -> Option<String> {
let payload = parse_config_share_link(share_link)?;
let config = serde_json::from_str::<NetworkConfig>(&payload.config_json).ok()?;
let config_id = config.instance_id.clone()?;
let display_name = display_name_override
.filter(|name| !name.is_empty())
.or(payload.display_name)
.unwrap_or_else(|| config_id.clone());
save_config_record(config_id.clone(), display_name, payload.config_json)?;
Some(config_id)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config_repo::{create_config_record, init_config_store};
use std::time::{SystemTime, UNIX_EPOCH};
fn test_root() -> String {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir()
.join(format!("easytier_ohrs_share_test_{unique}"))
.to_string_lossy()
.into_owned()
}
#[test]
fn share_link_roundtrip_works() {
assert!(init_config_store(test_root()));
create_config_record("cfg-share".to_string(), "share-demo".to_string())
.expect("create config");
let link = build_config_share_link("cfg-share", None, true).expect("share link");
let payload = parse_config_share_link(&link).expect("parse link");
let config =
serde_json::from_str::<NetworkConfig>(&payload.config_json).expect("config json");
assert!(payload.only_start);
assert_eq!(payload.display_name.as_deref(), Some("share-demo"));
assert_ne!(config.instance_id.as_deref(), Some("cfg-share"));
let imported_id = import_config_share_link(&link, None).expect("import link");
assert_ne!(imported_id, "cfg-share");
}
}
@@ -0,0 +1,333 @@
use crate::config::types::stored_config::{StoredConfigList, StoredConfigMeta};
use ohos_hilog_binding::{hilog_debug, hilog_error};
use rusqlite::{Connection, OptionalExtension, params};
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
static CONFIG_DB_PATH: Mutex<Option<PathBuf>> = Mutex::new(None);
const CONFIG_DB_FILE_NAME: &str = "easytier-config-store.db";
#[derive(Debug, Clone)]
struct StoredConfigMetaRecord {
config_id: String,
display_name: String,
created_at: String,
updated_at: String,
favorite: bool,
temporary: bool,
}
pub(crate) fn now_ts_string() -> String {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs().to_string())
.unwrap_or_else(|_| "0".to_string())
}
fn db_file_path() -> Option<PathBuf> {
CONFIG_DB_PATH
.lock()
.ok()
.and_then(|guard| guard.as_ref().cloned())
}
fn init_schema(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch(
"PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS stored_configs (
config_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
favorite INTEGER NOT NULL DEFAULT 0,
temporary INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS stored_config_fields (
config_id TEXT NOT NULL,
field_name TEXT NOT NULL,
field_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (config_id, field_name),
FOREIGN KEY (config_id) REFERENCES stored_configs(config_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_stored_config_fields_config_id
ON stored_config_fields(config_id);",
)
}
pub(crate) fn open_db() -> Option<Connection> {
let path = db_file_path()?;
let conn = match Connection::open(&path) {
Ok(conn) => conn,
Err(e) => {
hilog_error!("[Rust] failed to open config db {}: {}", path.display(), e);
return None;
}
};
if let Err(e) = init_schema(&conn) {
hilog_error!(
"[Rust] failed to initialize config db {}: {}",
path.display(),
e
);
return None;
}
Some(conn)
}
fn row_to_meta(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoredConfigMetaRecord> {
Ok(StoredConfigMetaRecord {
config_id: row.get(0)?,
display_name: row.get(1)?,
created_at: row.get(2)?,
updated_at: row.get(3)?,
favorite: row.get::<_, i64>(4)? != 0,
temporary: row.get::<_, i64>(5)? != 0,
})
}
fn load_meta_record(conn: &Connection, config_id: &str) -> Option<StoredConfigMetaRecord> {
conn.query_row(
"SELECT config_id, display_name, created_at, updated_at, favorite, temporary
FROM stored_configs WHERE config_id = ?1",
params![config_id],
row_to_meta,
)
.optional()
.ok()
.flatten()
}
fn to_meta(record: StoredConfigMetaRecord) -> StoredConfigMeta {
StoredConfigMeta {
config_id: record.config_id,
display_name: record.display_name,
created_at: record.created_at,
updated_at: record.updated_at,
favorite: record.favorite,
temporary: record.temporary,
}
}
pub fn init_config_meta_store(root_dir: String) -> bool {
let root = PathBuf::from(root_dir);
if let Err(e) = std::fs::create_dir_all(&root) {
hilog_error!(
"[Rust] failed to create config db dir {}: {}",
root.display(),
e
);
return false;
}
let db_path = root.join(CONFIG_DB_FILE_NAME);
match CONFIG_DB_PATH.lock() {
Ok(mut guard) => {
*guard = Some(db_path.clone());
}
Err(e) => {
hilog_error!("[Rust] failed to lock config db path: {}", e);
return false;
}
}
if open_db().is_none() {
return false;
}
hilog_debug!("[Rust] initialized config db at {}", db_path.display());
true
}
pub fn list_config_meta_entries() -> StoredConfigList {
let Some(conn) = open_db() else {
return StoredConfigList { configs: vec![] };
};
let mut stmt = match conn.prepare(
"SELECT config_id, display_name, created_at, updated_at, favorite, temporary
FROM stored_configs
ORDER BY updated_at DESC, display_name ASC",
) {
Ok(stmt) => stmt,
Err(e) => {
hilog_error!("[Rust] failed to prepare list meta query: {}", e);
return StoredConfigList { configs: vec![] };
}
};
let rows = match stmt.query_map([], row_to_meta) {
Ok(rows) => rows,
Err(e) => {
hilog_error!("[Rust] failed to list config meta rows: {}", e);
return StoredConfigList { configs: vec![] };
}
};
let configs = rows.filter_map(Result::ok).map(to_meta).collect();
StoredConfigList { configs }
}
pub fn get_config_display_name(config_id: &str) -> Option<String> {
let conn = open_db()?;
load_meta_record(&conn, config_id).map(|record| record.display_name)
}
pub fn get_config_meta(config_id: &str) -> Option<StoredConfigMeta> {
let conn = open_db()?;
load_meta_record(&conn, config_id).map(to_meta)
}
pub fn upsert_config_meta(
config_id: String,
display_name: String,
favorite: bool,
temporary: bool,
) -> StoredConfigMeta {
let now = now_ts_string();
let Some(conn) = open_db() else {
return StoredConfigMeta {
config_id,
display_name,
created_at: now.clone(),
updated_at: now,
favorite,
temporary,
};
};
let created_at = load_meta_record(&conn, &config_id)
.map(|record| record.created_at)
.unwrap_or_else(|| now.clone());
if let Err(e) = conn.execute(
"INSERT INTO stored_configs (
config_id, display_name, created_at, updated_at, favorite, temporary
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(config_id) DO UPDATE SET
display_name = excluded.display_name,
updated_at = excluded.updated_at,
favorite = excluded.favorite,
temporary = excluded.temporary",
params![
config_id,
display_name,
created_at,
now,
if favorite { 1 } else { 0 },
if temporary { 1 } else { 0 }
],
) {
hilog_error!("[Rust] failed to upsert config meta: {}", e);
}
get_config_meta(&config_id).unwrap_or(StoredConfigMeta {
config_id,
display_name,
created_at,
updated_at: now,
favorite,
temporary,
})
}
pub(crate) fn upsert_config_meta_in_tx(
tx: &rusqlite::Transaction<'_>,
config_id: String,
display_name: String,
favorite: bool,
temporary: bool,
) -> Option<StoredConfigMeta> {
let now = now_ts_string();
let created_at = tx
.query_row(
"SELECT config_id, display_name, created_at, updated_at, favorite, temporary
FROM stored_configs WHERE config_id = ?1",
params![config_id],
row_to_meta,
)
.optional()
.ok()
.flatten()
.map(|record| record.created_at)
.unwrap_or_else(|| now.clone());
tx.execute(
"INSERT INTO stored_configs (
config_id, display_name, created_at, updated_at, favorite, temporary
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(config_id) DO UPDATE SET
display_name = excluded.display_name,
updated_at = excluded.updated_at,
favorite = excluded.favorite,
temporary = excluded.temporary",
params![
config_id,
display_name,
created_at,
now,
if favorite { 1 } else { 0 },
if temporary { 1 } else { 0 }
],
)
.ok()?;
tx.query_row(
"SELECT config_id, display_name, created_at, updated_at, favorite, temporary
FROM stored_configs WHERE config_id = ?1",
params![config_id],
row_to_meta,
)
.optional()
.ok()
.flatten()
.map(to_meta)
.or(Some(StoredConfigMeta {
config_id,
display_name,
created_at,
updated_at: now,
favorite,
temporary,
}))
}
pub fn set_config_display_name(
config_id: String,
display_name: String,
) -> Option<StoredConfigMeta> {
let conn = open_db()?;
let mut record = load_meta_record(&conn, &config_id)?;
record.display_name = display_name;
record.updated_at = now_ts_string();
conn.execute(
"UPDATE stored_configs
SET display_name = ?2, updated_at = ?3
WHERE config_id = ?1",
params![config_id, record.display_name, record.updated_at],
)
.ok()?;
Some(to_meta(record))
}
pub fn delete_config_meta(config_id: &str) -> bool {
let Some(conn) = open_db() else {
return false;
};
match conn.execute(
"DELETE FROM stored_configs WHERE config_id = ?1",
params![config_id],
) {
Ok(rows) => rows > 0,
Err(e) => {
hilog_error!("[Rust] failed to delete config meta {}: {}", config_id, e);
false
}
}
}
@@ -0,0 +1 @@
pub(crate) mod config_meta;
@@ -0,0 +1 @@
pub(crate) mod stored_config;
@@ -0,0 +1,68 @@
use napi_derive_ohos::napi;
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
#[napi(object)]
pub struct StoredConfigMeta {
pub config_id: String,
pub display_name: String,
pub created_at: String,
pub updated_at: String,
pub favorite: bool,
pub temporary: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
#[napi(object)]
pub struct StoredConfigRecord {
pub meta: StoredConfigMeta,
pub config_json: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
#[napi(object)]
pub struct StoredConfigList {
pub configs: Vec<StoredConfigMeta>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
#[napi(object)]
pub struct ExportTomlResult {
pub toml_text: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
#[napi(object)]
pub struct StoredConfigSummary {
pub config_id: String,
pub display_name: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
#[napi(object)]
pub struct SharedConfigLinkPayload {
pub config_json: String,
pub display_name: Option<String>,
pub only_start: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
#[napi(object)]
pub struct LocalSocketSyncMessage {
pub message_type: String,
pub payload_json: String,
}
#[derive(Debug, Clone, Serialize)]
#[napi(object)]
pub struct KeyValuePair {
pub key: String,
pub value: String,
}
@@ -0,0 +1,349 @@
use super::{field_store, import_export, legacy_migration, validation};
use crate::config::storage::config_meta::{
delete_config_meta, get_config_meta, init_config_meta_store, list_config_meta_entries, open_db,
upsert_config_meta_in_tx,
};
use crate::config::types::stored_config::{ExportTomlResult, StoredConfigRecord};
use easytier::common::config::ConfigLoader;
use easytier::proto::api::manage::NetworkConfig;
use ohos_hilog_binding::{hilog_debug, hilog_error};
use rusqlite::params;
use serde_json::Value;
use std::path::PathBuf;
use std::sync::Mutex;
static CONFIG_ROOT_DIR: Mutex<Option<PathBuf>> = Mutex::new(None);
pub(crate) const CONFIG_DIR_NAME: &str = "easytier-configs";
pub(crate) const KERNEL_SOCKET_FILE_NAME: &str = "easytier-kernel.sock";
pub(crate) fn config_root_dir() -> Option<PathBuf> {
CONFIG_ROOT_DIR
.lock()
.ok()
.and_then(|guard| guard.as_ref().cloned())
}
pub(crate) fn kernel_socket_path() -> Option<PathBuf> {
config_root_dir().map(|root| root.join(KERNEL_SOCKET_FILE_NAME))
}
pub(crate) fn legacy_config_file_path(config_id: &str) -> Option<PathBuf> {
legacy_migration::legacy_config_file_path(&config_root_dir(), CONFIG_DIR_NAME, config_id)
}
pub fn init_config_store(root_dir: String) -> bool {
let root = PathBuf::from(root_dir);
let configs_dir = root.join(CONFIG_DIR_NAME);
if let Err(e) = std::fs::create_dir_all(&configs_dir) {
hilog_error!(
"[Rust] failed to create config dir {}: {}",
configs_dir.display(),
e
);
return false;
}
match CONFIG_ROOT_DIR.lock() {
Ok(mut guard) => {
*guard = Some(root.clone());
}
Err(e) => {
hilog_error!("[Rust] failed to lock config root dir: {}", e);
return false;
}
}
if !init_config_meta_store(root.to_string_lossy().into_owned()) {
return false;
}
hilog_debug!(
"[Rust] initialized config repo at {}",
configs_dir.display()
);
true
}
fn migrate_legacy_file_if_needed(config_id: &str) -> Option<()> {
legacy_migration::migrate_legacy_file_if_needed(
&config_root_dir(),
CONFIG_DIR_NAME,
config_id,
save_config_record,
)
}
pub fn save_config_record(
config_id: String,
display_name: String,
config_json: String,
) -> Option<StoredConfigRecord> {
let config = match validation::validate_config_json(&config_json, config_id.clone()) {
Ok(config) => config,
Err(e) => {
hilog_error!("[Rust] save_config_record failed {}", e);
return None;
}
};
let normalized_json = match serde_json::to_string(&config) {
Ok(raw) => raw,
Err(e) => {
hilog_error!(
"[Rust] failed to serialize normalized config {}: {}",
config_id,
e
);
return None;
}
};
let fields = match validation::config_to_top_level_map(&config) {
Some(fields) => fields,
None => return None,
};
let conn = open_db()?;
let tx = conn.unchecked_transaction().ok()?;
let existing_meta = get_config_meta(&config_id);
let favorite = existing_meta
.as_ref()
.map(|meta| meta.favorite)
.unwrap_or(false);
let temporary = existing_meta
.as_ref()
.map(|meta| meta.temporary)
.unwrap_or(false);
let meta = upsert_config_meta_in_tx(&tx, config_id.clone(), display_name, favorite, temporary)?;
field_store::replace_config_fields(&tx, &config_id, fields)?;
tx.commit().ok()?;
if let Some(legacy_path) = legacy_config_file_path(&config_id) {
if legacy_path.exists() {
let _ = std::fs::remove_file(legacy_path);
}
}
Some(StoredConfigRecord {
meta,
config_json: normalized_json,
})
}
pub fn load_config_json(config_id: &str) -> Option<String> {
migrate_legacy_file_if_needed(config_id)?;
let object = field_store::load_config_map_from_db(config_id)?;
serde_json::to_string(&Value::Object(object)).ok()
}
pub fn get_config_record(config_id: &str) -> Option<StoredConfigRecord> {
let config_json = load_config_json(config_id)?;
let meta = get_config_meta(config_id)?;
Some(StoredConfigRecord { meta, config_json })
}
pub fn get_config_field_value(config_id: &str, field: &str) -> Option<String> {
migrate_legacy_file_if_needed(config_id)?;
let conn = open_db()?;
conn.query_row(
"SELECT field_json FROM stored_config_fields
WHERE config_id = ?1 AND field_name = ?2",
params![config_id, field],
|row| row.get::<_, String>(0),
)
.ok()
}
pub fn set_config_field_value(config_id: &str, field: &str, json_value: &str) -> bool {
if field.contains('.') {
return false;
}
let raw = match load_config_json(config_id) {
Some(raw) => raw,
None => return false,
};
let mut value = match serde_json::from_str::<Value>(&raw) {
Ok(value) => value,
Err(_) => return false,
};
let new_field_value = match serde_json::from_str::<Value>(json_value) {
Ok(value) => value,
Err(_) => return false,
};
let object = match value.as_object_mut() {
Some(object) => object,
None => return false,
};
object.insert(field.to_string(), new_field_value);
let normalized = match serde_json::to_string(&value) {
Ok(raw) => raw,
Err(_) => return false,
};
let display_name = get_config_meta(config_id)
.map(|meta| meta.display_name)
.unwrap_or_else(|| config_id.to_string());
save_config_record(config_id.to_string(), display_name, normalized).is_some()
}
pub fn get_display_name(config_id: &str) -> Option<String> {
get_config_meta(config_id).map(|meta| meta.display_name)
}
pub fn get_default_config_json() -> Option<String> {
crate::build_default_network_config_json().ok()
}
pub fn create_config_record(config_id: String, display_name: String) -> Option<StoredConfigRecord> {
let raw = get_default_config_json()?;
let mut config = serde_json::from_str::<NetworkConfig>(&raw).ok()?;
config.instance_id = Some(config_id.clone());
let normalized_json = serde_json::to_string(&config).ok()?;
save_config_record(config_id, display_name, normalized_json)
}
pub fn start_kernel_with_config_id(config_id: &str) -> bool {
let raw = match load_config_json(config_id) {
Some(raw) => raw,
None => return false,
};
crate::run_network_instance_from_json(&raw)
}
pub fn list_config_meta_json() -> String {
serde_json::to_string(&list_config_meta_entries().configs).unwrap_or_else(|_| "[]".to_string())
}
pub fn delete_config_record(config_id: &str) -> bool {
if let Some(path) = legacy_config_file_path(config_id) {
if path.exists() {
let _ = std::fs::remove_file(path);
}
}
let conn = match open_db() {
Some(conn) => conn,
None => return false,
};
if let Err(e) = conn.execute(
"DELETE FROM stored_config_fields WHERE config_id = ?1",
params![config_id],
) {
hilog_error!("[Rust] failed to delete config fields {}: {}", config_id, e);
return false;
}
delete_config_meta(config_id)
}
pub fn export_config_toml(config_id: &str) -> Option<ExportTomlResult> {
let record = get_config_record(config_id)?;
import_export::export_config_toml_from_record(&record)
}
pub fn import_toml_config(
toml_text: String,
display_name: Option<String>,
) -> Option<StoredConfigRecord> {
import_export::import_toml_to_record(toml_text, display_name, save_config_record)
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::params;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
fn test_root() -> String {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("easytier_ohrs_test_{}", unique));
dir.to_string_lossy().into_owned()
}
#[test]
fn save_get_export_delete_roundtrip() {
let root = test_root();
assert!(init_config_store(root.clone()));
let config_json = crate::build_default_network_config_json().expect("default config");
let saved = save_config_record("cfg-1".to_string(), "test-config".to_string(), config_json)
.expect("save config");
assert_eq!(saved.meta.config_id, "cfg-1");
assert_eq!(saved.meta.display_name, "test-config");
let loaded = get_config_record("cfg-1").expect("load config");
assert_eq!(loaded.meta.display_name, "test-config");
assert!(loaded.config_json.contains("cfg-1"));
let legacy_json_path = PathBuf::from(&root)
.join(CONFIG_DIR_NAME)
.join("cfg-1.json");
assert!(
!legacy_json_path.exists(),
"config should no longer be persisted as a per-config json file"
);
let conn = open_db().expect("db should be open");
let field_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM stored_config_fields WHERE config_id = ?1",
params!["cfg-1"],
|row| row.get(0),
)
.expect("count config fields");
assert!(field_count > 0, "config fields should be stored in sqlite");
let exported = export_config_toml("cfg-1").expect("export toml");
assert!(exported.toml_text.contains("instance_id"));
assert!(delete_config_record("cfg-1"));
assert!(get_config_record("cfg-1").is_none());
}
#[test]
fn set_config_field_updates_only_requested_top_level_field() {
let root = test_root();
assert!(init_config_store(root));
let config_json = crate::build_default_network_config_json().expect("default config");
save_config_record(
"cfg-field".to_string(),
"field-config".to_string(),
config_json,
)
.expect("save config");
let before_network_name = get_config_field_value("cfg-field", "network_name");
let before_instance_id = get_config_field_value("cfg-field", "instance_id")
.expect("instance id field should exist");
assert!(set_config_field_value(
"cfg-field",
"network_name",
"\"changed-network\""
));
assert_eq!(
get_config_field_value("cfg-field", "network_name"),
Some("\"changed-network\"".to_string())
);
assert_eq!(
get_config_field_value("cfg-field", "instance_id"),
Some(before_instance_id)
);
assert_ne!(
get_config_field_value("cfg-field", "network_name"),
before_network_name
);
}
}
@@ -0,0 +1,67 @@
use crate::config::storage::config_meta::{now_ts_string, open_db};
use ohos_hilog_binding::hilog_error;
use rusqlite::{Connection, params};
use serde_json::{Map, Value};
pub(super) fn load_config_map_from_db(config_id: &str) -> Option<Map<String, Value>> {
let conn = open_db()?;
let mut stmt = conn
.prepare(
"SELECT field_name, field_json
FROM stored_config_fields
WHERE config_id = ?1",
)
.ok()?;
let rows = stmt
.query_map(params![config_id], |row| {
let field_name: String = row.get(0)?;
let field_json: String = row.get(1)?;
Ok((field_name, field_json))
})
.ok()?;
let mut object = Map::new();
for row in rows {
let (field_name, field_json) = row.ok()?;
let value = serde_json::from_str::<Value>(&field_json).ok()?;
object.insert(field_name, value);
}
if object.is_empty() {
None
} else {
Some(object)
}
}
pub(super) fn replace_config_fields(
tx: &Connection,
config_id: &str,
fields: Map<String, Value>,
) -> Option<()> {
if let Err(e) = tx.execute(
"DELETE FROM stored_config_fields WHERE config_id = ?1",
params![config_id],
) {
hilog_error!(
"[Rust] failed to clear existing config fields {}: {}",
config_id,
e
);
return None;
}
for (field_name, value) in fields {
let field_json = serde_json::to_string(&value).ok()?;
if let Err(e) = tx.execute(
"INSERT INTO stored_config_fields (config_id, field_name, field_json, updated_at)
VALUES (?1, ?2, ?3, ?4)",
params![config_id, field_name, field_json, now_ts_string()],
) {
hilog_error!("[Rust] failed to persist config field {}: {}", config_id, e);
return None;
}
}
Some(())
}
@@ -0,0 +1,48 @@
use crate::config::types::stored_config::{ExportTomlResult, StoredConfigRecord};
use easytier::common::config::{ConfigLoader, TomlConfigLoader};
use easytier::proto::api::manage::NetworkConfig;
pub(super) fn export_config_toml_from_record(
record: &StoredConfigRecord,
) -> Option<ExportTomlResult> {
let config = serde_json::from_str::<NetworkConfig>(&record.config_json).ok()?;
let toml = config.gen_config().ok()?;
Some(ExportTomlResult {
toml_text: toml.dump(),
})
}
pub(super) fn import_toml_to_record(
toml_text: String,
display_name: Option<String>,
save_config_record: impl Fn(String, String, String) -> Option<StoredConfigRecord>,
) -> Option<StoredConfigRecord> {
let config =
NetworkConfig::new_from_config(TomlConfigLoader::new_from_str(&toml_text).ok()?).ok()?;
let config_id = config.instance_id.clone()?;
let name_from_toml = toml_text
.lines()
.find_map(|line| {
let trimmed = line.trim();
if !trimmed.starts_with("instance_name") {
return None;
}
trimmed.split_once('=').map(|(_, value)| {
value
.trim()
.trim_matches('"')
.trim_matches('\'')
.to_string()
})
})
.filter(|name| !name.is_empty());
let final_name = display_name
.filter(|name| !name.is_empty())
.or(name_from_toml)
.unwrap_or_else(|| config_id.clone());
let config_json = serde_json::to_string(&config).ok()?;
save_config_record(config_id, final_name, config_json)
}
@@ -0,0 +1,45 @@
use crate::config::storage::config_meta::get_config_meta;
use ohos_hilog_binding::hilog_error;
use std::path::PathBuf;
pub(super) fn legacy_config_file_path(
root_dir: &Option<PathBuf>,
config_dir_name: &str,
config_id: &str,
) -> Option<PathBuf> {
root_dir.as_ref().map(|root| {
root.join(config_dir_name)
.join(format!("{}.json", config_id))
})
}
pub(super) fn migrate_legacy_file_if_needed(
root_dir: &Option<PathBuf>,
config_dir_name: &str,
config_id: &str,
save_config_record: impl Fn(
String,
String,
String,
) -> Option<crate::config::types::stored_config::StoredConfigRecord>,
) -> Option<()> {
let legacy_path = legacy_config_file_path(root_dir, config_dir_name, config_id)?;
if !legacy_path.exists() {
return Some(());
}
let raw = std::fs::read_to_string(&legacy_path).ok()?;
let display_name = get_config_meta(config_id)
.map(|meta| meta.display_name)
.unwrap_or_else(|| config_id.to_string());
save_config_record(config_id.to_string(), display_name, raw)?;
if let Err(e) = std::fs::remove_file(&legacy_path) {
hilog_error!(
"[Rust] failed to remove legacy config file {}: {}",
legacy_path.display(),
e
);
}
Some(())
}
@@ -0,0 +1,30 @@
use easytier::proto::api::manage::NetworkConfig;
use serde_json::{Map, Value};
pub(super) fn normalize_config_id(
mut config: NetworkConfig,
requested_id: String,
) -> Result<NetworkConfig, String> {
if requested_id.is_empty() {
return Err("config_id is required".to_string());
}
config.instance_id = Some(requested_id);
Ok(config)
}
pub(super) fn validate_config_json(
config_json: &str,
config_id: String,
) -> Result<NetworkConfig, String> {
let config = serde_json::from_str::<NetworkConfig>(config_json)
.map_err(|e| format!("parse config json failed: {}", e))?;
let config = normalize_config_id(config, config_id)?;
config
.gen_config()
.map_err(|e| format!("generate toml failed: {}", e))?;
Ok(config)
}
pub(super) fn config_to_top_level_map(config: &NetworkConfig) -> Option<Map<String, Value>> {
serde_json::to_value(config).ok()?.as_object().cloned()
}
@@ -0,0 +1,2 @@
pub(crate) mod config_api;
pub(crate) mod runtime_api;
@@ -0,0 +1,46 @@
use crate::config;
pub(crate) fn init_config_store(root_dir: String) -> bool {
config::repository::init_config_store(root_dir)
}
pub(crate) fn list_configs() -> String {
config::repository::list_config_meta_json()
}
pub(crate) fn save_config(config_id: String, display_name: String, config_json: String) -> bool {
config::repository::save_config_record(config_id, display_name, config_json).is_some()
}
pub(crate) fn create_config(config_id: String, display_name: String) -> bool {
config::repository::create_config_record(config_id, display_name).is_some()
}
pub(crate) fn delete_stored_config_meta(config_id: String) -> bool {
config::repository::delete_config_record(&config_id)
}
pub(crate) fn get_config(config_id: String) -> Option<String> {
config::repository::load_config_json(&config_id)
}
pub(crate) fn get_default_config() -> Option<String> {
config::repository::get_default_config_json()
}
pub(crate) fn get_config_field(config_id: String, field: String) -> Option<String> {
config::repository::get_config_field_value(&config_id, &field)
}
pub(crate) fn set_config_field(config_id: String, field: String, json_value: String) -> bool {
config::repository::set_config_field_value(&config_id, &field, &json_value)
}
pub(crate) fn import_toml(toml_text: String, display_name: Option<String>) -> Option<String> {
config::repository::import_toml_config(toml_text, display_name)
.map(|record| record.meta.config_id)
}
pub(crate) fn export_toml(config_id: String) -> Option<String> {
config::repository::export_config_toml(&config_id).map(|ret| ret.toml_text)
}
@@ -0,0 +1,184 @@
use crate::config::repository::load_config_json;
use crate::config::storage::config_meta::get_config_display_name;
use crate::config::types::stored_config::KeyValuePair;
use crate::kernel_bridge::{
aggregate_requested_tun_routes, start_local_socket_server as start_local_socket_server_inner,
stop_local_socket_server as stop_local_socket_server_inner,
};
use crate::runtime::state::runtime_state::{
RuntimeAggregateState, TunAggregateState, clear_tun_attached, mark_tun_attached,
runtime_instance_from_running_info,
};
use crate::{ASYNC_RUNTIME, EASYTIER_VERSION, INSTANCE_MANAGER, WEB_CLIENTS};
use easytier::proto::api::manage::NetworkConfig;
use ohos_hilog_binding::{hilog_error, hilog_info};
use std::sync::Arc;
pub(crate) fn start_kernel(
config_id: String,
start_kernel_with_config_id: impl Fn(&str) -> bool,
) -> bool {
start_kernel_with_config_id(&config_id)
}
pub(crate) fn stop_kernel(
config_id: String,
stop_web_client: impl Fn(&str) -> bool,
parse_instance_uuid: impl Fn(&str) -> Option<uuid::Uuid>,
maybe_stop_local_socket_server: impl Fn(),
) -> bool {
clear_tun_attached(&config_id);
if stop_web_client(&config_id) {
return true;
}
let Some(instance_id) = parse_instance_uuid(&config_id) else {
return false;
};
let ret = INSTANCE_MANAGER
.delete_network_instance(vec![instance_id])
.map(|_| true)
.unwrap_or_else(|err| {
hilog_error!("[Rust] stop_kernel failed {}: {}", config_id, err);
false
});
maybe_stop_local_socket_server();
ret
}
pub(crate) fn stop_network_instance(
config_ids: Vec<String>,
stop_kernel: impl Fn(String) -> bool,
) -> bool {
let mut ok = true;
for config_id in config_ids {
ok = stop_kernel(config_id) && ok;
}
ok
}
pub(crate) fn collect_network_infos() -> Vec<KeyValuePair> {
let infos = match INSTANCE_MANAGER.collect_network_infos_sync() {
Ok(infos) => infos,
Err(err) => {
hilog_error!("[Rust] collect network infos failed {}", err);
return vec![];
}
};
infos
.into_iter()
.filter_map(|(key, value)| {
serde_json::to_string(&value)
.ok()
.map(|value_json| KeyValuePair {
key: key.to_string(),
value: value_json,
})
})
.collect()
}
pub(crate) fn set_tun_fd(
config_id: String,
fd: i32,
parse_instance_uuid: impl Fn(&str) -> Option<uuid::Uuid>,
) -> bool {
let Some(instance_id) = parse_instance_uuid(&config_id) else {
hilog_error!("[Rust] set_tun_fd invalid instance id: {}", config_id);
return false;
};
INSTANCE_MANAGER
.set_tun_fd(&instance_id, fd)
.map(|_| {
mark_tun_attached(&config_id);
hilog_info!(
"[Rust] set_tun_fd success instance={} fd={} marked_attached=true",
config_id,
fd
);
true
})
.unwrap_or_else(|err| {
hilog_error!("[Rust] set_tun_fd failed {}: {}", config_id, err);
false
})
}
pub(crate) fn get_runtime_snapshot() -> RuntimeAggregateState {
get_runtime_snapshot_inner()
}
pub(crate) fn get_runtime_snapshot_inner() -> RuntimeAggregateState {
let infos = match INSTANCE_MANAGER.collect_network_infos_sync() {
Ok(infos) => infos,
Err(err) => {
hilog_error!("[Rust] collect network infos failed {}", err);
return RuntimeAggregateState {
instances: vec![],
tun: TunAggregateState {
active: false,
attached_instance_ids: vec![],
aggregated_routes: vec![],
dns_servers: vec![],
need_rebuild: false,
},
running_instance_count: 0,
};
}
};
let mut instances = Vec::with_capacity(infos.len());
for (instance_uuid, info) in infos {
let config_id = instance_uuid.to_string();
let display_name = get_config_display_name(&config_id).unwrap_or_else(|| config_id.clone());
let config_json = load_config_json(&config_id);
let stored_config = config_json
.as_deref()
.and_then(|raw| serde_json::from_str::<NetworkConfig>(raw).ok());
let magic_dns_enabled = stored_config
.as_ref()
.and_then(|cfg| cfg.enable_magic_dns)
.unwrap_or(false);
let need_exit_node = stored_config
.as_ref()
.map(|cfg| !cfg.exit_nodes.is_empty())
.unwrap_or(false);
instances.push(runtime_instance_from_running_info(
config_id,
display_name,
magic_dns_enabled,
need_exit_node,
info,
));
}
instances.sort_by(|a, b| {
a.display_name
.cmp(&b.display_name)
.then_with(|| a.instance_id.cmp(&b.instance_id))
});
let attached_instance_ids = instances
.iter()
.filter(|instance| instance.tun_required)
.map(|instance| instance.instance_id.clone())
.collect::<Vec<_>>();
let aggregated_routes = aggregate_requested_tun_routes(&instances);
let running_instance_count =
instances.iter().filter(|instance| instance.running).count() as i32;
let tun_active = !attached_instance_ids.is_empty();
RuntimeAggregateState {
instances,
tun: TunAggregateState {
active: tun_active,
attached_instance_ids,
aggregated_routes,
dns_servers: vec![],
need_rebuild: false,
},
running_instance_count,
}
}
@@ -0,0 +1,6 @@
mod protocol;
mod routing;
mod socket_server;
pub(crate) use routing::aggregate_requested_tun_routes;
pub use socket_server::{start_local_socket_server, stop_local_socket_server};
@@ -0,0 +1,50 @@
use crate::config::types::stored_config::LocalSocketSyncMessage;
use serde::Serialize;
use std::io::{Error, ErrorKind, Write};
use std::os::unix::net::UnixStream;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct TunRequestPayload {
pub config_id: String,
pub instance_id: String,
pub display_name: String,
pub virtual_ipv4: Option<String>,
pub virtual_ipv4_cidr: Option<String>,
pub aggregated_routes: Vec<String>,
pub magic_dns_enabled: bool,
pub need_exit_node: bool,
}
pub(crate) fn send_local_socket_message(
stream: &mut UnixStream,
message_type: &str,
payload_json: String,
) -> std::io::Result<()> {
let message = LocalSocketSyncMessage {
message_type: message_type.to_string(),
payload_json,
};
let mut raw = serde_json::to_vec(&message)
.map_err(|err| Error::new(ErrorKind::InvalidData, err.to_string()))?;
raw.push(b'\n');
stream.write_all(&raw)?;
Ok(())
}
pub(crate) fn broadcast_local_socket_message(
clients: &mut Vec<UnixStream>,
message_type: &str,
payload_json: &str,
) -> bool {
let mut active_clients = Vec::with_capacity(clients.len());
let mut delivered = false;
for mut client in clients.drain(..) {
if send_local_socket_message(&mut client, message_type, payload_json.to_string()).is_ok() {
delivered = true;
active_clients.push(client);
}
}
*clients = active_clients;
delivered
}
@@ -0,0 +1,105 @@
use crate::config::repository::load_config_json;
use crate::runtime::state::runtime_state::RuntimeInstanceState;
use easytier::proto::api::manage::NetworkConfig;
use ipnet::IpNet;
use ohos_hilog_binding::hilog_debug;
use std::collections::HashSet;
use std::net::IpAddr;
pub(crate) fn load_manual_routes(config_id: &str) -> Vec<String> {
load_config_json(config_id)
.and_then(|raw| serde_json::from_str::<NetworkConfig>(&raw).ok())
.map(|config| config.routes)
.unwrap_or_default()
}
fn normalize_route_cidr(route: &str) -> Option<String> {
route
.parse::<IpNet>()
.ok()
.map(|network| match network {
IpNet::V4(net) => net.trunc().to_string(),
IpNet::V6(net) => net.trunc().to_string(),
})
.or_else(|| {
route.parse::<IpAddr>().ok().map(|addr| match addr {
IpAddr::V4(ip) => format!("{}/32", ip),
IpAddr::V6(ip) => format!("{}/128", ip),
})
})
}
fn simplify_routes(routes: Vec<String>) -> Vec<String> {
let mut parsed = routes
.into_iter()
.filter_map(|route| normalize_route_cidr(&route))
.filter_map(|route| route.parse::<IpNet>().ok())
.collect::<Vec<_>>();
parsed.sort_by(|left, right| {
left.prefix_len()
.cmp(&right.prefix_len())
.then_with(|| left.network().to_string().cmp(&right.network().to_string()))
});
let mut simplified = Vec::<IpNet>::new();
'outer: for route in parsed {
for existing in &simplified {
if existing.contains(&route.network()) && existing.prefix_len() <= route.prefix_len() {
continue 'outer;
}
}
simplified.retain(|existing| {
!(route.contains(&existing.network()) && route.prefix_len() <= existing.prefix_len())
});
simplified.push(route);
}
let mut seen = HashSet::new();
simplified
.into_iter()
.map(|route| route.to_string())
.filter(|route| seen.insert(route.clone()))
.collect()
}
pub(crate) fn aggregate_tun_routes(instance: &RuntimeInstanceState) -> Vec<String> {
let virtual_ipv4_cidr = instance
.my_node_info
.as_ref()
.and_then(|info| info.virtual_ipv4_cidr.clone());
let manual_routes = load_manual_routes(&instance.config_id);
let proxy_cidrs = instance
.routes
.iter()
.flat_map(|route| route.proxy_cidrs.iter().cloned())
.collect::<Vec<_>>();
let mut raw_routes = Vec::new();
if let Some(cidr) = virtual_ipv4_cidr.clone() {
raw_routes.push(cidr);
}
raw_routes.extend(manual_routes.iter().cloned());
raw_routes.extend(proxy_cidrs.iter().cloned());
let aggregated_routes = simplify_routes(raw_routes);
hilog_debug!(
"[Rust] aggregate_tun_routes instance={} proxy_cidrs={:?} aggregated_routes={:?}",
instance.instance_id,
proxy_cidrs,
aggregated_routes
);
aggregated_routes
}
pub(crate) fn aggregate_requested_tun_routes(instances: &[RuntimeInstanceState]) -> Vec<String> {
let mut aggregated_routes = Vec::new();
let mut seen_routes = HashSet::new();
for instance in instances.iter().filter(|instance| instance.tun_required) {
for route in aggregate_tun_routes(instance) {
if seen_routes.insert(route.clone()) {
aggregated_routes.push(route);
}
}
}
aggregated_routes
}
@@ -0,0 +1,196 @@
use super::protocol::{TunRequestPayload, broadcast_local_socket_message};
use crate::config::repository::kernel_socket_path;
use crate::get_runtime_snapshot_inner;
use crate::kernel_bridge::routing::aggregate_tun_routes;
use ohos_hilog_binding::{hilog_error, hilog_info};
use once_cell::sync::Lazy;
use std::collections::{HashMap, HashSet};
use std::io::ErrorKind;
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::PathBuf;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread::{self, JoinHandle};
use std::time::Duration;
struct LocalSocketState {
stop_flag: std::sync::Arc<AtomicBool>,
socket_path: PathBuf,
worker: JoinHandle<()>,
}
static LOCAL_SOCKET_STATE: Lazy<Mutex<Option<LocalSocketState>>> = Lazy::new(|| Mutex::new(None));
pub fn start_local_socket_server() -> bool {
let socket_path = match kernel_socket_path() {
Some(path) => path,
None => {
hilog_error!("[Rust] kernel socket path unavailable");
return false;
}
};
match LOCAL_SOCKET_STATE.lock() {
Ok(guard) if guard.is_some() => return true,
Ok(_) => {}
Err(err) => {
hilog_error!("[Rust] lock localsocket state failed: {}", err);
return false;
}
}
if socket_path.exists() {
let _ = std::fs::remove_file(&socket_path);
}
let listener = match UnixListener::bind(&socket_path) {
Ok(listener) => listener,
Err(err) => {
hilog_error!(
"[Rust] bind localsocket failed {}: {}",
socket_path.display(),
err
);
return false;
}
};
if let Err(err) = listener.set_nonblocking(true) {
hilog_error!("[Rust] set localsocket nonblocking failed: {}", err);
let _ = std::fs::remove_file(&socket_path);
return false;
}
let stop_flag = std::sync::Arc::new(AtomicBool::new(false));
let worker_stop_flag = stop_flag.clone();
let worker = thread::spawn(move || {
let mut last_snapshot_json = String::new();
let mut delivered_tun_requests = HashSet::new();
let mut last_tun_route_signatures = HashMap::<String, String>::new();
let mut clients = Vec::<UnixStream>::new();
while !worker_stop_flag.load(Ordering::Relaxed) {
let mut accepted_client = false;
loop {
match listener.accept() {
Ok((stream, _addr)) => {
accepted_client = true;
clients.push(stream);
}
Err(err) if err.kind() == ErrorKind::WouldBlock => break,
Err(err) => {
hilog_error!("[Rust] accept localsocket failed: {}", err);
break;
}
}
}
let snapshot = get_runtime_snapshot_inner();
let snapshot_json = match serde_json::to_string(&snapshot) {
Ok(json) => json,
Err(err) => {
hilog_error!("[Rust] serialize runtime snapshot failed: {}", err);
thread::sleep(Duration::from_millis(250));
continue;
}
};
if accepted_client || snapshot_json != last_snapshot_json {
let _ = broadcast_local_socket_message(
&mut clients,
"runtime_snapshot",
&snapshot_json,
);
last_snapshot_json = snapshot_json;
}
for instance in snapshot.instances.iter() {
if instance.running && instance.tun_required {
let virtual_ipv4 = instance
.my_node_info
.as_ref()
.and_then(|info| info.virtual_ipv4.clone());
let virtual_ipv4_cidr = instance
.my_node_info
.as_ref()
.and_then(|info| info.virtual_ipv4_cidr.clone());
if clients.is_empty() {
continue;
}
if virtual_ipv4.is_none() || virtual_ipv4_cidr.is_none() {
continue;
}
let aggregated_routes = aggregate_tun_routes(instance);
let route_signature = serde_json::to_string(&aggregated_routes)
.unwrap_or_else(|_| "[]".to_string());
let should_send = !delivered_tun_requests.contains(&instance.instance_id)
|| last_tun_route_signatures
.get(&instance.instance_id)
.map(|value| value != &route_signature)
.unwrap_or(true);
if !should_send {
continue;
}
let payload = TunRequestPayload {
config_id: instance.config_id.clone(),
instance_id: instance.instance_id.clone(),
display_name: instance.display_name.clone(),
virtual_ipv4,
virtual_ipv4_cidr,
aggregated_routes,
magic_dns_enabled: instance.magic_dns_enabled,
need_exit_node: instance.need_exit_node,
};
let payload_json = match serde_json::to_string(&payload) {
Ok(json) => json,
Err(err) => {
hilog_error!("[Rust] serialize tun request failed: {}", err);
continue;
}
};
if broadcast_local_socket_message(&mut clients, "tun_request", &payload_json) {
delivered_tun_requests.insert(instance.instance_id.clone());
last_tun_route_signatures
.insert(instance.instance_id.clone(), route_signature);
}
} else {
delivered_tun_requests.remove(&instance.instance_id);
last_tun_route_signatures.remove(&instance.instance_id);
}
}
thread::sleep(Duration::from_millis(250));
}
});
match LOCAL_SOCKET_STATE.lock() {
Ok(mut guard) => {
*guard = Some(LocalSocketState {
stop_flag,
socket_path,
worker,
});
true
}
Err(err) => {
hilog_error!("[Rust] lock localsocket state failed: {}", err);
false
}
}
}
pub fn stop_local_socket_server() -> bool {
let state = match LOCAL_SOCKET_STATE.lock() {
Ok(mut guard) => guard.take(),
Err(err) => {
hilog_error!("[Rust] lock localsocket state failed: {}", err);
return false;
}
};
if let Some(state) = state {
state.stop_flag.store(true, Ordering::Relaxed);
let _ = state.worker.join();
let _ = std::fs::remove_file(state.socket_path);
}
true
}
+439 -139
View File
@@ -1,185 +1,485 @@
mod native_log;
mod config;
mod exports;
mod kernel_bridge;
mod platform;
mod runtime;
use easytier::common::config::{ConfigFileControl, ConfigLoader, TomlConfigLoader};
use config::repository::{
create_config_record, delete_config_record, export_config_toml, get_config_field_value,
get_default_config_json, import_toml_config, init_config_store as init_repo_store,
list_config_meta_json, save_config_record, set_config_field_value, start_kernel_with_config_id,
};
use config::services::schema_service::{
ConfigFieldMapping, NetworkConfigSchema,
get_network_config_field_mappings as build_network_config_field_mappings,
get_network_config_schema as build_network_config_schema,
};
use config::services::share_link_service::{
build_config_share_link as build_config_share_link_inner,
import_config_share_link as import_config_share_link_inner,
parse_config_share_link as parse_config_share_link_inner,
};
use config::storage::config_meta::get_config_display_name;
use config::types::stored_config::{KeyValuePair, SharedConfigLinkPayload};
use easytier::common::constants::EASYTIER_VERSION;
use easytier::common::{
MachineIdOptions,
config::{ConfigFileControl, ConfigLoader, TomlConfigLoader},
};
use easytier::instance_manager::NetworkInstanceManager;
use easytier::proto::api::manage::NetworkConfig;
use easytier::proto::api::manage::NetworkingMethod;
use easytier::web_client::{WebClient, WebClientHooks, run_web_client};
use kernel_bridge::{
aggregate_requested_tun_routes, start_local_socket_server as start_local_socket_server_inner,
stop_local_socket_server as stop_local_socket_server_inner,
};
use napi_derive_ohos::napi;
use ohos_hilog_binding::{hilog_debug, hilog_error};
use ohos_hilog_binding::{hilog_error, hilog_info};
use runtime::state::runtime_state::{
RuntimeAggregateState, TunAggregateState, clear_tun_attached, mark_tun_attached,
runtime_instance_from_running_info,
};
use std::collections::{HashMap, HashSet};
use std::format;
use std::sync::{Arc, Mutex};
use tokio::runtime::{Builder, Runtime};
use uuid::Uuid;
static INSTANCE_MANAGER: once_cell::sync::Lazy<NetworkInstanceManager> =
once_cell::sync::Lazy::new(NetworkInstanceManager::new);
pub(crate) static INSTANCE_MANAGER: once_cell::sync::Lazy<Arc<NetworkInstanceManager>> =
once_cell::sync::Lazy::new(|| Arc::new(NetworkInstanceManager::new()));
static ASYNC_RUNTIME: once_cell::sync::Lazy<Runtime> = once_cell::sync::Lazy::new(|| {
Builder::new_multi_thread()
.enable_all()
.build()
.expect("tokio runtime for easytier-ohrs")
});
static WEB_CLIENTS: once_cell::sync::Lazy<Mutex<HashMap<String, ManagedWebClient>>> =
once_cell::sync::Lazy::new(|| Mutex::new(HashMap::new()));
#[napi(object)]
pub struct KeyValuePair {
pub key: String,
pub value: String,
#[derive(Default)]
struct TrackedWebClientHooks {
instance_ids: Mutex<HashSet<Uuid>>,
}
#[napi]
pub fn easytier_version() -> String {
EASYTIER_VERSION.to_string()
struct ManagedWebClient {
_client: WebClient,
hooks: Arc<TrackedWebClientHooks>,
}
#[napi]
pub fn set_tun_fd(inst_id: String, fd: i32) -> bool {
match Uuid::try_parse(&inst_id) {
Ok(uuid) => match INSTANCE_MANAGER.set_tun_fd(&uuid, fd) {
Ok(_) => {
hilog_debug!("[Rust] set tun fd {} to {}.", fd, inst_id);
true
}
Err(e) => {
hilog_error!("[Rust] cant set tun fd {} to {}. {}", fd, inst_id, e);
false
}
},
Err(e) => {
hilog_error!("[Rust] cant covert {} to uuid. {}", inst_id, e);
#[async_trait::async_trait]
impl WebClientHooks for TrackedWebClientHooks {
async fn post_run_network_instance(&self, id: &Uuid) -> Result<(), String> {
self.instance_ids
.lock()
.map_err(|err| err.to_string())?
.insert(*id);
Ok(())
}
async fn post_remove_network_instances(&self, ids: &[Uuid]) -> Result<(), String> {
let mut guard = self.instance_ids.lock().map_err(|err| err.to_string())?;
for id in ids {
guard.remove(id);
}
Ok(())
}
}
fn is_config_server_config(config: &NetworkConfig) -> bool {
matches!(
NetworkingMethod::try_from(config.networking_method.unwrap_or_default())
.unwrap_or_default(),
NetworkingMethod::PublicServer
) && config
.public_server_url
.as_ref()
.is_some_and(|url| !url.trim().is_empty())
}
fn stop_web_client(config_id: &str) -> bool {
let managed = match WEB_CLIENTS.lock() {
Ok(mut guard) => guard.remove(config_id),
Err(err) => {
hilog_error!("[Rust] stop_web_client lock failed {}", err);
return false;
}
};
let Some(managed) = managed else {
return false;
};
let tracked_ids = managed
.hooks
.instance_ids
.lock()
.map(|guard| guard.iter().copied().collect::<Vec<_>>())
.unwrap_or_default();
drop(managed);
if tracked_ids.is_empty() {
maybe_stop_local_socket_server();
return true;
}
let ret = INSTANCE_MANAGER
.delete_network_instance(tracked_ids)
.map(|_| true)
.unwrap_or_else(|err| {
hilog_error!(
"[Rust] stop config server instances failed {}: {}",
config_id,
err
);
false
});
maybe_stop_local_socket_server();
ret
}
fn ensure_local_socket_server_started() -> bool {
start_local_socket_server_inner()
}
fn maybe_stop_local_socket_server() {
let no_local_instances = INSTANCE_MANAGER.list_network_instance_ids().is_empty();
let no_web_clients = WEB_CLIENTS
.lock()
.map(|guard| guard.is_empty())
.unwrap_or(false);
if no_local_instances && no_web_clients {
let _ = stop_local_socket_server_inner();
}
}
fn run_config_server_instance(config_id: &str, config: &NetworkConfig) -> bool {
if INSTANCE_MANAGER
.list_network_instance_ids()
.iter()
.next()
.is_some()
{
hilog_error!("[Rust] there is a running instance!");
return false;
}
let Some(config_server_url) = config.public_server_url.clone() else {
hilog_error!("[Rust] public_server_url missing for config server mode");
return false;
};
let hooks = Arc::new(TrackedWebClientHooks::default());
let secure_mode = config
.secure_mode
.as_ref()
.map(|mode| mode.enabled)
.unwrap_or(false);
let hostname = config.hostname.clone();
if !ensure_local_socket_server_started() {
return false;
}
let client = ASYNC_RUNTIME.block_on(run_web_client(
&config_server_url,
MachineIdOptions::default(),
hostname,
secure_mode,
INSTANCE_MANAGER.clone(),
Some(hooks.clone()),
));
let client = match client {
Ok(client) => client,
Err(err) => {
hilog_error!("[Rust] start config server failed {}", err);
return false;
}
};
match WEB_CLIENTS.lock() {
Ok(mut guard) => {
guard.insert(
config_id.to_string(),
ManagedWebClient {
_client: client,
hooks,
},
);
true
}
Err(err) => {
hilog_error!("[Rust] store config server client failed {}", err);
false
}
}
}
#[napi]
pub fn default_network_config() -> String {
match NetworkConfig::new_from_config(TomlConfigLoader::default()) {
Ok(result) => serde_json::to_string(&result).unwrap_or_else(|e| format!("ERROR {}", e)),
Err(e) => {
hilog_error!("[Rust] default_network_config failed {}", e);
format!("ERROR {}", e)
}
}
pub(crate) fn build_default_network_config_json() -> Result<String, String> {
let config = NetworkConfig::new_from_config(TomlConfigLoader::default())
.map_err(|e| format!("default_network_config failed {}", e))?;
serde_json::to_string(&config).map_err(|e| format!("default_network_config failed {}", e))
}
#[napi]
pub fn convert_toml_to_network_config(cfg_str: String) -> String {
match TomlConfigLoader::new_from_str(&cfg_str) {
Ok(cfg) => match NetworkConfig::new_from_config(cfg) {
Ok(result) => serde_json::to_string(&result).unwrap_or_else(|e| format!("ERROR {}", e)),
Err(e) => {
hilog_error!("[Rust] convert_toml_to_network_config failed {}", e);
format!("ERROR {}", e)
}
},
Err(e) => {
hilog_error!("[Rust] convert_toml_to_network_config failed {}", e);
format!("ERROR {}", e)
}
}
fn convert_toml_to_network_config_inner(toml_text: &str) -> Result<String, String> {
let config = NetworkConfig::new_from_config(
TomlConfigLoader::new_from_str(toml_text).map_err(|e| e.to_string())?,
)
.map_err(|e| e.to_string())?;
serde_json::to_string(&config).map_err(|e| e.to_string())
}
#[napi]
pub fn parse_network_config(cfg_json: String) -> bool {
match serde_json::from_str::<NetworkConfig>(&cfg_json) {
Ok(cfg) => match cfg.gen_config() {
Ok(toml) => {
hilog_debug!("[Rust] Convert to Toml {}", toml.dump());
true
}
Err(e) => {
hilog_error!("[Rust] parse config failed {}", e);
false
}
},
Err(e) => {
hilog_error!("[Rust] parse config failed {}", e);
false
}
}
fn parse_network_config_inner(cfg_json: &str) -> bool {
serde_json::from_str::<NetworkConfig>(cfg_json)
.ok()
.and_then(|cfg| cfg.gen_config().ok())
.is_some()
}
#[napi]
pub fn run_network_instance(cfg_json: String) -> bool {
let cfg = match serde_json::from_str::<NetworkConfig>(&cfg_json) {
Ok(cfg) => match cfg.gen_config() {
Ok(toml) => toml,
Err(e) => {
hilog_error!("[Rust] parse config failed {}", e);
return false;
}
},
pub(crate) fn run_network_instance_from_json(cfg_json: &str) -> bool {
let config = match serde_json::from_str::<NetworkConfig>(cfg_json) {
Ok(cfg) => cfg,
Err(e) => {
hilog_error!("[Rust] parse config failed {}", e);
return false;
}
};
if INSTANCE_MANAGER.list_network_instance_ids().len() > 0 {
if is_config_server_config(&config) {
let Some(config_id) = config.instance_id.as_deref() else {
hilog_error!("[Rust] config server config missing instance id");
return false;
};
return run_config_server_instance(config_id, &config);
}
let cfg = match config.gen_config() {
Ok(toml) => toml,
Err(e) => {
hilog_error!("[Rust] parse config failed {}", e);
return false;
}
};
if !INSTANCE_MANAGER.list_network_instance_ids().is_empty() {
hilog_error!("[Rust] there is a running instance!");
return false;
}
if !ensure_local_socket_server_started() {
return false;
}
let inst_id = cfg.get_id();
if INSTANCE_MANAGER
.list_network_instance_ids()
.contains(&inst_id)
{
hilog_error!("[Rust] instance {} already exists", inst_id);
return false;
}
INSTANCE_MANAGER
.run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG)
.unwrap();
true
}
#[napi]
pub fn stop_network_instance(inst_names: Vec<String>) {
INSTANCE_MANAGER
.delete_network_instance(
inst_names
.into_iter()
.filter_map(|s| Uuid::parse_str(&s).ok())
.collect(),
)
.unwrap();
hilog_debug!("[Rust] stop_network_instance");
}
#[napi]
pub fn collect_network_infos() -> Vec<KeyValuePair> {
let mut result = Vec::new();
match INSTANCE_MANAGER.collect_network_infos_sync() {
Ok(map) => {
for (uuid, info) in map.iter() {
// convert value to json string
let value = match serde_json::to_string(&info) {
Ok(value) => value,
Err(e) => {
hilog_error!("[Rust] failed to serialize instance {} info: {}", uuid, e);
continue;
}
};
result.push(KeyValuePair {
key: uuid.clone().to_string(),
value: value.clone(),
});
}
}
Err(_) => {}
}
result
}
#[napi]
pub fn collect_running_network() -> Vec<String> {
INSTANCE_MANAGER
.list_network_instance_ids()
.clone()
.into_iter()
.map(|id| id.to_string())
.collect()
}
#[napi]
pub fn is_running_network(inst_id: String) -> bool {
match Uuid::try_parse(&inst_id) {
Ok(uuid) => INSTANCE_MANAGER.list_network_instance_ids().contains(&uuid),
Err(e) => {
hilog_error!("[Rust] cant covert {} to uuid. {}", inst_id, e);
match INSTANCE_MANAGER.run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG) {
Ok(_) => true,
Err(err) => {
hilog_error!("[Rust] start_kernel failed for {}: {}", inst_id, err);
false
}
}
}
fn parse_instance_uuid(config_id: &str) -> Option<Uuid> {
match Uuid::parse_str(config_id) {
Ok(uuid) => Some(uuid),
Err(err) => {
hilog_error!("[Rust] invalid config_id {}: {}", config_id, err);
None
}
}
}
#[napi]
pub fn init_config_store(root_dir: String) -> bool {
exports::config_api::init_config_store(root_dir)
}
#[napi]
pub fn list_configs() -> String {
exports::config_api::list_configs()
}
#[napi]
pub fn get_config_display_name_by_id(config_id: String) -> Option<String> {
get_config_display_name(&config_id)
}
#[napi]
pub fn save_config(config_id: String, display_name: String, config_json: String) -> bool {
exports::config_api::save_config(config_id, display_name, config_json)
}
#[napi]
pub fn create_config(config_id: String, display_name: String) -> bool {
exports::config_api::create_config(config_id, display_name)
}
#[napi]
pub fn rename_stored_config(config_id: String, display_name: String) -> bool {
config::storage::config_meta::set_config_display_name(config_id, display_name).is_some()
}
#[napi]
pub fn delete_stored_config_meta(config_id: String) -> bool {
exports::config_api::delete_stored_config_meta(config_id)
}
#[napi]
pub fn get_config(config_id: String) -> Option<String> {
exports::config_api::get_config(config_id)
}
#[napi]
pub fn get_default_config() -> Option<String> {
exports::config_api::get_default_config()
}
#[napi]
pub fn get_config_field(config_id: String, field: String) -> Option<String> {
exports::config_api::get_config_field(config_id, field)
}
#[napi]
pub fn set_config_field(config_id: String, field: String, json_value: String) -> bool {
exports::config_api::set_config_field(config_id, field, json_value)
}
#[napi]
pub fn import_toml(toml_text: String, display_name: Option<String>) -> Option<String> {
exports::config_api::import_toml(toml_text, display_name)
}
#[napi]
pub fn export_toml(config_id: String) -> Option<String> {
exports::config_api::export_toml(config_id)
}
#[napi]
pub fn start_kernel(config_id: String) -> bool {
exports::runtime_api::start_kernel(config_id, start_kernel_with_config_id)
}
#[napi]
pub fn stop_kernel(config_id: String) -> bool {
exports::runtime_api::stop_kernel(
config_id,
stop_web_client,
parse_instance_uuid,
maybe_stop_local_socket_server,
)
}
#[napi]
pub fn stop_network_instance(config_ids: Vec<String>) -> bool {
exports::runtime_api::stop_network_instance(config_ids, stop_kernel)
}
#[napi]
pub fn easytier_version() -> String {
EASYTIER_VERSION.to_string()
}
#[napi]
pub fn default_network_config() -> String {
get_default_config().unwrap_or_else(|| "{}".to_string())
}
#[napi]
pub fn convert_toml_to_network_config(toml_text: String) -> String {
convert_toml_to_network_config_inner(&toml_text).unwrap_or_else(|err| format!("ERROR: {err}"))
}
#[napi]
pub fn parse_network_config(cfg_json: String) -> bool {
parse_network_config_inner(&cfg_json)
}
#[napi]
pub fn run_network_instance(cfg_json: String) -> bool {
run_network_instance_from_json(&cfg_json)
}
#[napi]
pub fn collect_network_infos() -> Vec<KeyValuePair> {
exports::runtime_api::collect_network_infos()
}
#[napi]
pub fn set_tun_fd(config_id: String, fd: i32) -> bool {
exports::runtime_api::set_tun_fd(config_id, fd, parse_instance_uuid)
}
#[napi]
pub fn get_network_config_schema() -> NetworkConfigSchema {
build_network_config_schema()
}
#[napi]
pub fn get_network_config_field_mappings() -> Vec<ConfigFieldMapping> {
build_network_config_field_mappings()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exported_plain_object_schema_contains_core_networkconfig_metadata() {
let schema = get_network_config_schema();
assert_eq!(schema.name, "NetworkConfig");
assert_eq!(schema.node_kind, "schema");
assert!(
schema
.children
.iter()
.any(|field| field.name == "network_name")
);
let secure_mode = schema
.children
.iter()
.find(|field| field.name == "secure_mode")
.expect("secure_mode field");
assert!(
secure_mode
.children
.iter()
.any(|field| field.name == "enabled")
);
}
}
#[napi]
pub fn get_runtime_snapshot() -> RuntimeAggregateState {
exports::runtime_api::get_runtime_snapshot()
}
pub(crate) fn get_runtime_snapshot_inner() -> RuntimeAggregateState {
exports::runtime_api::get_runtime_snapshot_inner()
}
#[napi]
pub fn build_config_share_link(config_id: String, only_start: Option<bool>) -> Option<String> {
build_config_share_link_inner(&config_id, None, only_start.unwrap_or(false))
}
#[napi]
pub fn parse_config_share_link(share_link: String) -> Option<SharedConfigLinkPayload> {
parse_config_share_link_inner(&share_link)
}
#[napi]
pub fn import_config_share_link(
share_link: String,
display_name_override: Option<String>,
) -> Option<String> {
import_config_share_link_inner(&share_link, display_name_override)
}
@@ -0,0 +1 @@
pub(crate) mod logging;
@@ -0,0 +1 @@
pub(crate) mod native_log;
@@ -0,0 +1 @@
pub(crate) mod state;
@@ -0,0 +1 @@
pub(crate) mod runtime_state;
@@ -0,0 +1,293 @@
use easytier::proto::{api, common};
use napi_derive_ohos::napi;
use serde::Serialize;
use std::collections::HashSet;
use std::sync::Mutex;
static ATTACHED_TUN_INSTANCE_IDS: once_cell::sync::Lazy<Mutex<HashSet<String>>> =
once_cell::sync::Lazy::new(|| Mutex::new(HashSet::new()));
pub fn mark_tun_attached(instance_id: &str) {
if let Ok(mut guard) = ATTACHED_TUN_INSTANCE_IDS.lock() {
guard.insert(instance_id.to_string());
}
}
pub fn clear_tun_attached(instance_id: &str) {
if let Ok(mut guard) = ATTACHED_TUN_INSTANCE_IDS.lock() {
guard.remove(instance_id);
}
}
pub fn is_tun_attached(instance_id: &str) -> bool {
ATTACHED_TUN_INSTANCE_IDS
.lock()
.map(|guard| guard.contains(instance_id))
.unwrap_or(false)
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
#[napi(object)]
pub struct PeerConnStats {
pub rx_bytes: i64,
pub tx_bytes: i64,
pub rx_packets: i64,
pub tx_packets: i64,
pub latency_us: i64,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
#[napi(object)]
pub struct PeerConnInfo {
pub conn_id: String,
pub my_peer_id: i64,
pub peer_id: i64,
pub features: Vec<String>,
pub tunnel_type: Option<String>,
pub local_addr: Option<String>,
pub remote_addr: Option<String>,
pub resolved_remote_addr: Option<String>,
pub stats: Option<PeerConnStats>,
pub loss_rate: Option<f64>,
pub is_client: bool,
pub network_name: Option<String>,
pub is_closed: bool,
pub secure_auth_level: Option<i32>,
pub peer_identity_type: Option<i32>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
#[napi(object)]
pub struct PeerInfo {
pub peer_id: i64,
pub default_conn_id: Option<String>,
pub directly_connected_conns: Vec<String>,
pub conns: Vec<PeerConnInfo>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
#[napi(object)]
pub struct RouteView {
pub peer_id: i64,
pub hostname: Option<String>,
pub ipv4: Option<String>,
pub ipv4_cidr: Option<String>,
pub ipv6_cidr: Option<String>,
pub proxy_cidrs: Vec<String>,
pub next_hop_peer_id: Option<i64>,
pub cost: Option<i32>,
pub path_latency: Option<i64>,
pub udp_nat_type: Option<i32>,
pub tcp_nat_type: Option<i32>,
pub inst_id: Option<String>,
pub version: Option<String>,
pub is_public_server: Option<bool>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
#[napi(object)]
pub struct MyNodeInfo {
pub virtual_ipv4: Option<String>,
pub virtual_ipv4_cidr: Option<String>,
pub hostname: Option<String>,
pub version: Option<String>,
pub peer_id: Option<i64>,
pub listeners: Vec<String>,
pub vpn_portal_cfg: Option<String>,
pub udp_nat_type: Option<i32>,
pub tcp_nat_type: Option<i32>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
#[napi(object)]
pub struct RuntimeInstanceState {
pub config_id: String,
pub instance_id: String,
pub display_name: String,
pub running: bool,
pub tun_required: bool,
pub tun_attached: bool,
pub magic_dns_enabled: bool,
pub need_exit_node: bool,
pub error_message: Option<String>,
pub my_node_info: Option<MyNodeInfo>,
pub events: Vec<String>,
pub routes: Vec<RouteView>,
pub peers: Vec<PeerInfo>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
#[napi(object)]
pub struct TunAggregateState {
pub active: bool,
pub attached_instance_ids: Vec<String>,
pub aggregated_routes: Vec<String>,
pub dns_servers: Vec<String>,
pub need_rebuild: bool,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
#[napi(object)]
pub struct RuntimeAggregateState {
pub instances: Vec<RuntimeInstanceState>,
pub tun: TunAggregateState,
pub running_instance_count: i32,
}
fn stringify_ipv4_inet(value: Option<common::Ipv4Inet>) -> Option<String> {
value.map(|v| v.to_string())
}
fn stringify_ipv6_inet(value: Option<common::Ipv6Inet>) -> Option<String> {
value.map(|v| v.to_string())
}
fn stringify_url(value: Option<common::Url>) -> Option<String> {
value.map(|v| v.to_string())
}
fn stringify_uuid(value: Option<common::Uuid>) -> Option<String> {
value.map(|v| v.to_string())
}
fn optional_u32_to_i64(value: Option<u32>) -> Option<i64> {
value.map(|v| v as i64)
}
fn optional_i32_to_i64(value: Option<i32>) -> Option<i64> {
value.map(|v| v as i64)
}
fn route_to_view(route: api::instance::Route) -> RouteView {
let stun = route.stun_info;
let feature_flag = route.feature_flag;
RouteView {
peer_id: route.peer_id as i64,
hostname: (!route.hostname.is_empty()).then_some(route.hostname),
ipv4: route
.ipv4_addr
.as_ref()
.and_then(|inet| inet.address.as_ref())
.map(|addr| addr.to_string()),
ipv4_cidr: stringify_ipv4_inet(route.ipv4_addr),
ipv6_cidr: stringify_ipv6_inet(route.ipv6_addr),
proxy_cidrs: route.proxy_cidrs,
next_hop_peer_id: optional_u32_to_i64(route.next_hop_peer_id_latency_first)
.or_else(|| Some(route.next_hop_peer_id as i64)),
cost: Some(route.cost),
path_latency: optional_i32_to_i64(route.path_latency_latency_first)
.or_else(|| Some(route.path_latency as i64)),
udp_nat_type: stun.as_ref().map(|info| info.udp_nat_type),
tcp_nat_type: stun.as_ref().map(|info| info.tcp_nat_type),
inst_id: (!route.inst_id.is_empty()).then_some(route.inst_id),
version: (!route.version.is_empty()).then_some(route.version),
is_public_server: feature_flag.map(|flag| flag.is_public_server),
}
}
fn peer_conn_to_view(conn: api::instance::PeerConnInfo) -> PeerConnInfo {
let stats = conn.stats.map(|stats| PeerConnStats {
rx_bytes: stats.rx_bytes as i64,
tx_bytes: stats.tx_bytes as i64,
rx_packets: stats.rx_packets as i64,
tx_packets: stats.tx_packets as i64,
latency_us: stats.latency_us as i64,
});
PeerConnInfo {
conn_id: conn.conn_id,
my_peer_id: conn.my_peer_id as i64,
peer_id: conn.peer_id as i64,
features: conn.features,
tunnel_type: conn.tunnel.as_ref().map(|t| t.tunnel_type.clone()),
local_addr: conn
.tunnel
.as_ref()
.and_then(|t| stringify_url(t.local_addr.clone())),
remote_addr: conn
.tunnel
.as_ref()
.and_then(|t| stringify_url(t.remote_addr.clone())),
resolved_remote_addr: conn
.tunnel
.as_ref()
.and_then(|t| stringify_url(t.resolved_remote_addr.clone())),
stats,
loss_rate: Some(conn.loss_rate as f64),
is_client: conn.is_client,
network_name: (!conn.network_name.is_empty()).then_some(conn.network_name),
is_closed: conn.is_closed,
secure_auth_level: Some(conn.secure_auth_level),
peer_identity_type: Some(conn.peer_identity_type),
}
}
fn peer_to_view(peer: api::instance::PeerInfo) -> PeerInfo {
PeerInfo {
peer_id: peer.peer_id as i64,
default_conn_id: stringify_uuid(peer.default_conn_id),
directly_connected_conns: peer
.directly_connected_conns
.into_iter()
.map(|id| id.to_string())
.collect(),
conns: peer.conns.into_iter().map(peer_conn_to_view).collect(),
}
}
fn my_node_info_to_view(info: api::manage::MyNodeInfo) -> MyNodeInfo {
MyNodeInfo {
virtual_ipv4: info
.virtual_ipv4
.as_ref()
.and_then(|inet| inet.address.as_ref())
.map(|addr| addr.to_string()),
virtual_ipv4_cidr: stringify_ipv4_inet(info.virtual_ipv4),
hostname: (!info.hostname.is_empty()).then_some(info.hostname),
version: (!info.version.is_empty()).then_some(info.version),
peer_id: Some(info.peer_id as i64),
listeners: info
.listeners
.into_iter()
.map(|url| url.to_string())
.collect(),
vpn_portal_cfg: info.vpn_portal_cfg,
udp_nat_type: info.stun_info.as_ref().map(|stun| stun.udp_nat_type),
tcp_nat_type: info.stun_info.as_ref().map(|stun| stun.tcp_nat_type),
}
}
pub fn runtime_instance_from_running_info(
config_id: String,
display_name: String,
magic_dns_enabled: bool,
need_exit_node: bool,
info: api::manage::NetworkInstanceRunningInfo,
) -> RuntimeInstanceState {
let tun_attached = info.running && is_tun_attached(&config_id);
let tun_required = info.running && (info.dev_name != "no_tun" || tun_attached);
RuntimeInstanceState {
config_id: config_id.clone(),
instance_id: config_id,
display_name,
running: info.running,
tun_required,
tun_attached,
magic_dns_enabled,
need_exit_node,
error_message: info.error_msg,
my_node_info: info.my_node_info.map(my_node_info_to_view),
events: info.events,
routes: info.routes.into_iter().map(route_to_view).collect(),
peers: info.peers.into_iter().map(peer_to_view).collect(),
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "easytier-gui",
"type": "module",
"version": "2.6.3",
"version": "2.6.4",
"private": true,
"packageManager": "pnpm@9.12.1+sha512.e5a7e52a4183a02d5931057f7a0dbff9d5e9ce3161e33fa68ae392125b79282a8a8a470a51dfc8a0ed86221442eb2fb57019b0990ed24fab519bf0e1bc5ccfc4",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "easytier-gui"
version = "2.6.3"
version = "2.6.4"
description = "EasyTier GUI"
authors = ["you"]
edition.workspace = true
+12 -7
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,
@@ -490,10 +488,18 @@ async fn init_web_client(app: AppHandle, url: Option<String>) -> Result<(), Stri
.ok_or_else(|| "Instance manager is not available".to_string())?;
let hooks = Arc::new(manager::GuiHooks { app: app.clone() });
let machine_id_state_dir = app
.path()
.app_data_dir()
.with_context(|| "Failed to resolve machine id state directory")
.map_err(|e| format!("{:#}", e))?;
let web_client = web_client::run_web_client(
url.as_str(),
None,
easytier::common::MachineIdOptions {
explicit_machine_id: None,
state_dir: Some(machine_id_state_dir),
},
None,
false,
instance_manager,
@@ -1318,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,
@@ -1326,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(());
};
+1 -1
View File
@@ -17,7 +17,7 @@
"createUpdaterArtifacts": false
},
"productName": "easytier-gui",
"version": "2.6.3",
"version": "2.6.4",
"identifier": "com.kkrainbow.easytier",
"plugins": {
"shell": {
-20
View File
@@ -1,20 +0,0 @@
[package]
name = "easytier-rpc-build"
description = "Protobuf RPC Service Generator for EasyTier"
version = "0.1.0"
edition.workspace = true
homepage = "https://github.com/EasyTier/EasyTier"
repository = "https://github.com/EasyTier/EasyTier"
authors = ["kkrainbow"]
keywords = ["vpn", "p2p", "network", "easytier"]
categories = ["network-programming", "command-line-utilities"]
license-file = "LICENSE"
readme = "README.md"
[dependencies]
heck = "0.5"
prost-build = "0.13"
[features]
default = []
internal-namespace = []
-1
View File
@@ -1 +0,0 @@
../LICENSE
-3
View File
@@ -1,3 +0,0 @@
# Introduction
This is a protobuf rpc service stub generator for [EasyTier](https://github.com/EasyTier/EasyTier) project.
-449
View File
@@ -1,449 +0,0 @@
extern crate heck;
extern crate prost_build;
use std::fmt;
#[cfg(feature = "internal-namespace")]
const NAMESPACE: &str = "crate::proto::rpc_types";
#[cfg(not(feature = "internal-namespace"))]
const NAMESPACE: &str = "easytier::proto::rpc_types";
/// The service generator to be used with `prost-build` to generate RPC implementations for
/// `prost-simple-rpc`.
///
/// See the crate-level documentation for more info.
#[allow(missing_copy_implementations)]
#[derive(Clone, Debug, Default)]
pub struct ServiceGenerator {
_private: (),
}
impl prost_build::ServiceGenerator for ServiceGenerator {
fn generate(&mut self, service: prost_build::Service, mut buf: &mut String) {
use std::fmt::Write;
let descriptor_name = format!("{}Descriptor", service.name);
let server_name = format!("{}Server", service.name);
let client_name = format!("{}Client", service.name);
let method_descriptor_name = format!("{}MethodDescriptor", service.name);
let mut trait_methods = String::new();
let mut weak_impl_methods = String::new();
let mut enum_methods = String::new();
let mut list_enum_methods = String::new();
let mut client_methods = String::new();
let mut client_own_methods = String::new();
let mut match_name_methods = String::new();
let mut match_proto_name_methods = String::new();
let mut match_input_type_methods = String::new();
let mut match_input_proto_type_methods = String::new();
let mut match_output_type_methods = String::new();
let mut match_output_proto_type_methods = String::new();
let mut match_handle_methods = String::new();
// generate trait default method Xxx::json_call_method match branch
let mut match_trait_json_methods = String::new();
let mut match_method_try_from = String::new();
for (idx, method) in service.methods.iter().enumerate() {
assert!(
!method.client_streaming,
"Client streaming not yet supported for method {}",
method.proto_name
);
assert!(
!method.server_streaming,
"Server streaming not yet supported for method {}",
method.proto_name
);
ServiceGenerator::write_comments(&mut trait_methods, 4, &method.comments).unwrap();
writeln!(
trait_methods,
r#" async fn {name}(&self, ctrl: Self::Controller, input: {input_type}) -> {namespace}::error::Result<{output_type}>;"#,
name = method.name,
input_type = method.input_type,
output_type = method.output_type,
namespace = NAMESPACE,
)
.unwrap();
writeln!(
weak_impl_methods,
r#" async fn {method_name}(&self, ctrl: Self::Controller, input: {input_type}) -> {namespace}::error::Result<{output_type}> {{
let Some(service) = self.upgrade() else {{
return Err({namespace}::error::Error::Shutdown);
}};
service.{method_name}(ctrl, input).await
}}"#,
method_name = method.name,
input_type = method.input_type,
output_type = method.output_type,
namespace = NAMESPACE,
)
.unwrap();
ServiceGenerator::write_comments(&mut enum_methods, 4, &method.comments).unwrap();
writeln!(
enum_methods,
" {name} = {index},",
name = method.proto_name,
index = idx + 1
)
.unwrap();
writeln!(
match_method_try_from,
" {index} => Ok({service_name}MethodDescriptor::{name}),",
service_name = service.name,
name = method.proto_name,
index = idx + 1,
)
.unwrap();
writeln!(
list_enum_methods,
" {service_name}MethodDescriptor::{name},",
service_name = service.name,
name = method.proto_name
)
.unwrap();
writeln!(
client_methods,
r#" async fn {name}(&self, ctrl: H::Controller, input: {input_type}) -> {namespace}::error::Result<{output_type}> {{
{client_name}Client::{name}_inner(self.0.clone(), ctrl, input).await
}}"#,
name = method.name,
input_type = method.input_type,
output_type = method.output_type,
client_name = service.name,
namespace = NAMESPACE,
)
.unwrap();
writeln!(
client_own_methods,
r#" async fn {name}_inner(handler: H, ctrl: H::Controller, input: {input_type}) -> {namespace}::error::Result<{output_type}> {{
{namespace}::__rt::call_method(handler, ctrl, {method_descriptor_name}::{proto_name}, input).await
}}"#,
name = method.name,
method_descriptor_name = method_descriptor_name,
proto_name = method.proto_name,
input_type = method.input_type,
output_type = method.output_type,
namespace = NAMESPACE,
).unwrap();
let case = format!(
" {service_name}MethodDescriptor::{proto_name} => ",
service_name = service.name,
proto_name = method.proto_name
);
writeln!(match_name_methods, "{}{:?},", case, method.name).unwrap();
writeln!(match_proto_name_methods, "{}{:?},", case, method.proto_name).unwrap();
writeln!(
match_input_type_methods,
"{}::std::any::TypeId::of::<{}>(),",
case, method.input_type
)
.unwrap();
writeln!(
match_input_proto_type_methods,
"{}{:?},",
case, method.input_proto_type
)
.unwrap();
writeln!(
match_output_type_methods,
"{}::std::any::TypeId::of::<{}>(),",
case, method.output_type
)
.unwrap();
writeln!(
match_output_proto_type_methods,
"{}{:?},",
case, method.output_proto_type
)
.unwrap();
write!(
match_handle_methods,
r#"{} {{
let decoded: {input_type} = {namespace}::__rt::decode(input)?;
let ret = service.{name}(ctrl, decoded).await?;
{namespace}::__rt::encode(ret)
}}
"#,
case,
input_type = method.input_type,
name = method.name,
namespace = NAMESPACE,
)
.unwrap();
write!(
match_trait_json_methods,
r#" "{name}" | "{proto_name}" => {{
let req: {input_type} = ::serde_json::from_value(json).map_err(|e| {namespace}::error::Error::MalformatRpcPacket(format!("json error: {{}}", e)))?;
let resp = self.{typed_method}(ctrl, req).await?;
Ok(::serde_json::to_value(resp).map_err(|e| {namespace}::error::Error::MalformatRpcPacket(format!("json error: {{}}", e)))?)
}}
"#,
name = method.name,
proto_name = method.proto_name,
input_type = method.input_type,
typed_method = method.name,
namespace = NAMESPACE,
)
.unwrap();
}
ServiceGenerator::write_comments(&mut buf, 0, &service.comments).unwrap();
write!(
buf,
r#"
#[async_trait::async_trait]
#[auto_impl::auto_impl(&, Arc, Box)]
pub trait {name} {{
type Controller: {namespace}::controller::Controller;
{trait_methods}
async fn json_call_method(
&self,
ctrl: Self::Controller,
method_name: &str,
json: ::serde_json::Value,
) -> {namespace}::error::Result<::serde_json::Value> {{
match method_name {{
{match_trait_json_methods}
_ => Err({namespace}::error::Error::InvalidMethodIndex(0, method_name.to_string())),
}}
}}
}}
#[async_trait::async_trait]
impl<T> {name} for ::std::sync::Weak<T>
where
T: Send + Sync + 'static,
::std::sync::Arc<T>: {name},
{{
type Controller = <::std::sync::Arc<T> as {name}>::Controller;
{weak_impl_methods}
}}
/// A service descriptor for a `{name}`.
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Default)]
pub struct {descriptor_name};
/// Methods available on a `{name}`.
///
/// This can be used as a key when routing requests for servers/clients of a `{name}`.
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
#[repr(u8)]
pub enum {method_descriptor_name} {{
{enum_methods}
}}
impl std::convert::TryFrom<u8> for {method_descriptor_name} {{
type Error = {namespace}::error::Error;
fn try_from(value: u8) -> {namespace}::error::Result<Self> {{
match value {{
{match_method_try_from}
_ => Err({namespace}::error::Error::InvalidMethodIndex(value, "{name}".to_string())),
}}
}}
}}
/// A client for a `{name}`.
///
/// This implements the `{name}` trait by dispatching all method calls to the supplied `Handler`.
#[derive(Clone, Debug)]
pub struct {client_name}<H>(H) where H: {namespace}::handler::Handler;
impl<H> {client_name}<H> where H: {namespace}::handler::Handler<Descriptor = {descriptor_name}> {{
/// Creates a new client instance that delegates all method calls to the supplied handler.
pub fn new(handler: H) -> {client_name}<H> {{
{client_name}(handler)
}}
}}
impl<H> {client_name}<H> where H: {namespace}::handler::Handler<Descriptor = {descriptor_name}> {{
{client_own_methods}
}}
#[async_trait::async_trait]
impl<H> {name} for {client_name}<H> where H: {namespace}::handler::Handler<Descriptor = {descriptor_name}> {{
type Controller = H::Controller;
{client_methods}
}}
pub struct {client_name}Factory<C: {namespace}::controller::Controller>(std::marker::PhantomData<C>);
impl<C: {namespace}::controller::Controller> Clone for {client_name}Factory<C> {{
fn clone(&self) -> Self {{
Self(std::marker::PhantomData)
}}
}}
impl<C> {namespace}::__rt::RpcClientFactory for {client_name}Factory<C> where C: {namespace}::controller::Controller {{
type Descriptor = {descriptor_name};
type ClientImpl = Box<dyn {name}<Controller = C> + Send + Sync + 'static>;
type Controller = C;
fn new(handler: impl {namespace}::handler::Handler<Descriptor = Self::Descriptor, Controller = Self::Controller>) -> Self::ClientImpl {{
Box::new({client_name}::new(handler))
}}
}}
/// A server for a `{name}`.
///
/// This implements the `Server` trait by handling requests and dispatch them to methods on the
/// supplied `{name}`.
#[derive(Clone, Debug)]
pub struct {server_name}<A>(A) where A: {name} + Clone + Send + 'static;
impl<T> {server_name}<::std::sync::Weak<T>>
where
T: Send + Sync + 'static,
::std::sync::Arc<T>: {name},
{{
pub fn new_arc(service: ::std::sync::Arc<T>) -> {server_name}<::std::sync::Weak<T>> {{
{server_name}(::std::sync::Arc::downgrade(&service))
}}
}}
impl<A> {server_name}<A> where A: {name} + Clone + Send + 'static {{
/// Creates a new server instance that dispatches all calls to the supplied service.
pub fn new(service: A) -> {server_name}<A> {{
{server_name}(service)
}}
async fn call_inner(
service: A,
method: {method_descriptor_name},
ctrl: A::Controller,
input: ::bytes::Bytes)
-> {namespace}::error::Result<::bytes::Bytes> {{
match method {{
{match_handle_methods}
}}
}}
}}
impl {namespace}::descriptor::ServiceDescriptor for {descriptor_name} {{
type Method = {method_descriptor_name};
fn name(&self) -> &'static str {{ {name:?} }}
fn proto_name(&self) -> &'static str {{ {proto_name:?} }}
fn package(&self) -> &'static str {{ {package:?} }}
fn methods(&self) -> &'static [Self::Method] {{
&[ {list_enum_methods} ]
}}
}}
#[async_trait::async_trait]
impl<A> {namespace}::handler::Handler for {server_name}<A>
where
A: {name} + Clone + Send + Sync + 'static {{
type Descriptor = {descriptor_name};
type Controller = A::Controller;
async fn call(
&self,
ctrl: A::Controller,
method: {method_descriptor_name},
input: ::bytes::Bytes)
-> {namespace}::error::Result<::bytes::Bytes> {{
{server_name}::call_inner(self.0.clone(), method, ctrl, input).await
}}
}}
impl {namespace}::descriptor::MethodDescriptor for {method_descriptor_name} {{
fn name(&self) -> &'static str {{
match *self {{
{match_name_methods}
}}
}}
fn proto_name(&self) -> &'static str {{
match *self {{
{match_proto_name_methods}
}}
}}
fn input_type(&self) -> ::std::any::TypeId {{
match *self {{
{match_input_type_methods}
}}
}}
fn input_proto_type(&self) -> &'static str {{
match *self {{
{match_input_proto_type_methods}
}}
}}
fn output_type(&self) -> ::std::any::TypeId {{
match *self {{
{match_output_type_methods}
}}
}}
fn output_proto_type(&self) -> &'static str {{
match *self {{
{match_output_proto_type_methods}
}}
}}
fn index(&self) -> u8 {{
*self as u8
}}
}}
"#,
name = service.name,
descriptor_name = descriptor_name,
server_name = server_name,
client_name = client_name,
method_descriptor_name = method_descriptor_name,
proto_name = service.proto_name,
package = service.package,
trait_methods = trait_methods,
weak_impl_methods = weak_impl_methods,
enum_methods = enum_methods,
list_enum_methods = list_enum_methods,
client_own_methods = client_own_methods,
client_methods = client_methods,
match_name_methods = match_name_methods,
match_proto_name_methods = match_proto_name_methods,
match_input_type_methods = match_input_type_methods,
match_input_proto_type_methods = match_input_proto_type_methods,
match_output_type_methods = match_output_type_methods,
match_output_proto_type_methods = match_output_proto_type_methods,
match_handle_methods = match_handle_methods,
match_trait_json_methods = match_trait_json_methods,
namespace = NAMESPACE,
).unwrap();
}
}
impl ServiceGenerator {
fn write_comments<W>(
mut write: W,
indent: usize,
comments: &prost_build::Comments,
) -> fmt::Result
where
W: fmt::Write,
{
for comment in &comments.leading {
for line in comment.lines().filter(|s| !s.is_empty()) {
writeln!(write, "{}///{}", " ".repeat(indent), line)?;
}
}
Ok(())
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "easytier-web"
version = "2.6.3"
version = "2.6.4"
edition.workspace = true
description = "Config server for easytier. easytier-core gets config from this and web frontend use it as restful api server."
@@ -99,6 +99,7 @@ const bool_flags: BoolFlag[] = [
{ field: 'disable_encryption', help: 'disable_encryption_help' },
{ field: 'disable_tcp_hole_punching', help: 'disable_tcp_hole_punching_help' },
{ field: 'disable_udp_hole_punching', help: 'disable_udp_hole_punching_help' },
{ field: 'enable_udp_broadcast_relay', help: 'enable_udp_broadcast_relay_help' },
{ field: 'disable_upnp', help: 'disable_upnp_help' },
{ field: 'disable_sym_hole_punching', help: 'disable_sym_hole_punching_help' },
{ field: 'enable_magic_dns', help: 'enable_magic_dns_help' },
@@ -160,6 +160,9 @@ disable_tcp_hole_punching_help: 禁用TCP打洞功能
disable_udp_hole_punching: 禁用UDP打洞
disable_udp_hole_punching_help: 禁用UDP打洞功能
enable_udp_broadcast_relay: UDP 广播中继
enable_udp_broadcast_relay_help: "仅 Windows:捕获物理网卡上的本机 UDP 广播包并转发给 EasyTier 对等节点,帮助局域网游戏发现房间。需要管理员权限。"
disable_upnp: 禁用 UPnP
disable_upnp_help: 禁用符合条件监听器的运行时 UPnP/NAT-PMP 端口映射;自动端口映射默认开启。
@@ -260,6 +263,7 @@ event:
DhcpIpv4Conflicted: DHCP IPv4地址冲突
PortForwardAdded: 端口转发添加
ProxyCidrsUpdated: 子网代理CIDR更新
UdpBroadcastRelayStartResult: UDP广播中继启动结果
web:
login:
@@ -159,6 +159,9 @@ disable_tcp_hole_punching_help: Disable tcp hole punching
disable_udp_hole_punching: Disable UDP Hole Punching
disable_udp_hole_punching_help: Disable udp hole punching
enable_udp_broadcast_relay: UDP Broadcast Relay
enable_udp_broadcast_relay_help: "Windows only: capture local UDP broadcast packets from physical interfaces and forward them to EasyTier peers. Helps games to find rooms in local network. Requires administrator privileges."
disable_upnp: Disable UPnP
disable_upnp_help: Disable runtime UPnP/NAT-PMP port mapping for eligible listeners; automatic port mapping is enabled by default.
@@ -260,6 +263,7 @@ event:
DhcpIpv4Conflicted: DhcpIpv4Conflicted
PortForwardAdded: PortForwardAdded
ProxyCidrsUpdated: ProxyCidrsUpdated
UdpBroadcastRelayStartResult: UDP Broadcast Relay Start Result
web:
login:
@@ -134,6 +134,7 @@ export interface NetworkConfig {
disable_tcp_hole_punching?: boolean
disable_udp_hole_punching?: boolean
disable_upnp?: boolean
enable_udp_broadcast_relay?: boolean
disable_sym_hole_punching?: boolean
enable_relay_network_whitelist?: boolean
@@ -211,6 +212,7 @@ export function DEFAULT_NETWORK_CONFIG(): NetworkConfig {
disable_tcp_hole_punching: false,
disable_udp_hole_punching: false,
disable_upnp: false,
enable_udp_broadcast_relay: false,
disable_sym_hole_punching: false,
enable_relay_network_whitelist: false,
relay_network_whitelist: [],
@@ -447,4 +449,6 @@ export enum EventType {
PortForwardAdded = 'PortForwardAdded', // PortForwardConfigPb
ProxyCidrsUpdated = 'ProxyCidrsUpdated', // string[], string[]
UdpBroadcastRelayStartResult = 'UdpBroadcastRelayStartResult', // { capture_backend?: string, error?: string }
}
+1
View File
@@ -365,6 +365,7 @@ mod tests {
let _c = WebClient::new(
connector,
"test",
uuid::Uuid::new_v4(),
"test",
false,
Arc::new(NetworkInstanceManager::new()),
+19 -15
View File
@@ -3,7 +3,7 @@ name = "easytier"
description = "A full meshed p2p VPN, connecting all your devices in one network with one command."
homepage = "https://github.com/EasyTier/EasyTier"
repository = "https://github.com/EasyTier/EasyTier"
version = "2.6.3"
version = "2.6.4"
edition.workspace = true
rust-version.workspace = true
authors = ["kkrainbow"]
@@ -11,6 +11,7 @@ keywords = ["vpn", "p2p", "network", "easytier"]
categories = ["network-programming", "command-line-utilities"]
license-file = "LICENSE"
readme = "README.md"
build = "build/main.rs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -50,6 +51,9 @@ time = "0.3"
toml = "0.8.12"
chrono = { version = "0.4.37", features = ["serde"] }
getset = "0.1.6"
optionize = "0.2"
guarden = "0.1"
delegate = "0.13.5"
@@ -58,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"] }
@@ -69,9 +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"
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"
@@ -150,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",
@@ -168,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 }
@@ -234,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"
@@ -315,15 +321,14 @@ jemalloc-sys = { package = "tikv-jemalloc-sys", version = "0.6.0", features = [
[build-dependencies]
cfg_aliases = "0.2.1"
tonic-build = "0.12"
indoc = "2.0"
globwalk = "0.8.1"
regex = "1"
prost-build = "0.13.5"
prost-wkt-build = "0.6"
easytier-rpc-build = { path = "../easytier-rpc-build", features = [
"internal-namespace",
] }
prost-reflect-build = { version = "0.14.0" }
proc-macro2 = "1"
quote = "1"
thunk-rs = { git = "https://github.com/easytier/thunk.git", default-features = false, features = [
"win7",
] }
@@ -337,7 +342,6 @@ zip = "4.0.0"
serial_test = "3.0.0"
rstest = "0.25.0"
futures-util = "0.3.31"
maplit = "1.0.2"
tempfile = "3.22.0"
ctor = "0.8.0"
@@ -399,7 +403,7 @@ jemalloc-prof = [
"jemalloc-sys/stats",
]
tracing = ["tokio/tracing", "dep:console-subscriber"]
magic-dns = ["dep:hickory-client", "dep:hickory-server"]
magic-dns = ["dep:hickory-server"]
faketcp = ["dep:flume"]
zstd = ["dep:zstd"]
# For Network Extension on macOS
+24 -16
View File
@@ -1,3 +1,6 @@
mod rpc;
use crate::rpc::ServiceGenerator;
use cfg_aliases::cfg_aliases;
use prost_wkt_build::{FileDescriptorSet, Message as _};
#[cfg(target_os = "windows")]
@@ -163,7 +166,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
"src/proto/api_config.proto",
"src/proto/api_manage.proto",
"src/proto/web.proto",
"src/proto/magic_dns.proto",
"src/proto/dns.proto",
"src/proto/acl.proto",
];
@@ -171,17 +174,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("cargo:rerun-if-changed={proto_file}");
}
let out = PathBuf::from(env::var("OUT_DIR").unwrap());
let descriptor_file = out.join("descriptors.bin");
let mut config = prost_build::Config::new();
config
.type_attribute(".", "#[derive(serde::Serialize,serde::Deserialize)]")
.extern_path(".google.protobuf.Any", "::prost_wkt_types::Any")
.extern_path(".google.protobuf.Timestamp", "::prost_wkt_types::Timestamp")
.extern_path(".google.protobuf.Value", "::prost_wkt_types::Value")
.file_descriptor_set_path(&descriptor_file)
.protoc_arg("--experimental_allow_proto3_optional")
.extern_path(".google.protobuf.Value", "::prost_wkt_types::Value");
config
.type_attribute(".", "#[derive(serde::Serialize,serde::Deserialize)]")
.type_attribute("peer_rpc.DirectConnectedPeerInfo", "#[derive(Hash)]")
.type_attribute("peer_rpc.PeerInfoForGlobalMap", "#[derive(Hash)]")
.type_attribute("peer_rpc.ForeignNetworkRouteInfoKey", "#[derive(Hash, Eq)]")
@@ -195,20 +195,28 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.type_attribute("acl.AclV1", "#[serde(default)]")
.type_attribute("acl.Chain", "#[serde(default)]")
.type_attribute("acl.Rule", "#[serde(default)]")
.type_attribute("acl.GroupInfo", "#[serde(default)]")
.field_attribute(".api.manage.NetworkConfig", "#[serde(default)]")
.service_generator(Box::new(easytier_rpc_build::ServiceGenerator::default()))
.btree_map(["."])
.skip_debug([".common.Ipv4Addr", ".common.Ipv6Addr", ".common.UUID"]);
.type_attribute("acl.GroupInfo", "#[serde(default)]");
config.compile_protos(&proto_files, &["src/proto/"])?;
config.field_attribute("api.manage.NetworkConfig", "#[serde(default)]");
config.skip_debug([".common.Ipv4Addr", ".common.Ipv6Addr", ".common.UUID"]);
let out = PathBuf::from(env::var("OUT_DIR")?);
let descriptor_file = out.join("descriptors.bin");
config
.btree_map(["."])
.service_generator(Box::new(ServiceGenerator::default()))
.protoc_arg("--experimental_allow_proto3_optional")
.file_descriptor_set_path(&descriptor_file)
.compile_protos(&proto_files, &["src/proto/"])?;
prost_reflect_build::Builder::new()
.file_descriptor_set_bytes("crate::proto::DESCRIPTOR_POOL_BYTES")
.compile_protos_with_config(config, &proto_files_reflect, &["src/proto/"])?;
let descriptor_bytes = std::fs::read(descriptor_file).unwrap();
let descriptor = FileDescriptorSet::decode(&descriptor_bytes[..]).unwrap();
let descriptor_bytes = std::fs::read(descriptor_file)?;
let descriptor = FileDescriptorSet::decode(&descriptor_bytes[..])?;
prost_wkt_build::add_serde(out, descriptor);
check_locale();
+720
View File
@@ -0,0 +1,720 @@
#![allow(non_snake_case)]
use indoc::formatdoc;
use proc_macro2::{Ident, TokenStream};
use quote::{format_ident, quote};
use std::str::FromStr;
fn parse(value: &str) -> TokenStream {
TokenStream::from_str(value)
.unwrap_or_else(|err| panic!("Failed to parse tokens: {} ({})", value, err))
}
fn doc(comments: &prost_build::Comments) -> TokenStream {
let doc = comments
.leading
.iter()
.flat_map(|c| c.lines().filter(|s| !s.is_empty()));
quote! { #( #[doc = #doc] )* }
}
const NAMESPACE: &str = "crate::proto::rpc_types";
struct Method {
index: u8,
doc: TokenStream,
method: Ident,
method_inner: Ident,
method_str: String,
method_proto: Ident,
method_proto_str: String,
Input: TokenStream,
Input_proto_str: String,
Output: TokenStream,
Output_proto_str: String,
}
impl Method {
fn new(index: u8, method: prost_build::Method) -> Self {
assert!(
!method.client_streaming,
"Client streaming not yet supported for method {}",
method.proto_name
);
assert!(
!method.server_streaming,
"Server streaming not yet supported for method {}",
method.proto_name
);
Self {
index,
doc: doc(&method.comments),
method: format_ident!("{}", method.name),
method_inner: format_ident!("{}_inner", method.name),
method_str: method.name,
method_proto: format_ident!("{}", method.proto_name),
method_proto_str: method.proto_name,
Input: parse(&method.input_type),
Input_proto_str: method.input_proto_type,
Output: parse(&method.output_type),
Output_proto_str: method.output_proto_type,
}
}
}
struct Service {
namespace: TokenStream,
doc: TokenStream,
Service: Ident,
ServiceDescriptor: Ident,
ServiceServer: Ident,
ServiceClient: Ident,
ServiceClientFactory: Ident,
ServiceMethodDescriptor: Ident,
Service_str: String,
Service_proto_str: String,
Service_package_str: String,
methods: Vec<Method>,
}
impl Service {
fn new(service: prost_build::Service) -> Self {
let methods = service
.methods
.into_iter()
.enumerate()
.map(|(i, method)| Method::new((i + 1) as u8, method))
.collect();
Self {
namespace: parse(NAMESPACE),
doc: doc(&service.comments),
Service: format_ident!("{}", service.name),
ServiceDescriptor: format_ident!("{}Descriptor", service.name),
ServiceServer: format_ident!("{}Server", service.name),
ServiceClient: format_ident!("{}Client", service.name),
ServiceClientFactory: format_ident!("{}ClientFactory", service.name),
ServiceMethodDescriptor: format_ident!("{}MethodDescriptor", service.name),
Service_str: service.name,
Service_proto_str: service.proto_name,
Service_package_str: service.package,
methods,
}
}
fn trait_Service(&self) -> TokenStream {
let Self {
namespace,
doc,
Service,
methods,
..
} = self;
let match_json_call_method = methods.iter().map(
|Method {
method,
method_str,
method_proto_str,
Input,
..
}| {
quote! {
#method_str | #method_proto_str => {
let req: #Input = ::serde_json::from_value(json)
.map_err(|e| #namespace::error::Error::MalformatRpcPacket(format!("json error: {}", e)))?;
let resp = self.#method(ctrl, req).await?;
Ok(::serde_json::to_value(resp)
.map_err(|e| #namespace::error::Error::MalformatRpcPacket(format!("json error: {}", e)))?)
}
}
},
);
let methods = methods.iter().map(
|Method {
doc,
method,
Input,
Output,
..
}| {
quote! {
#doc
async fn #method(&self, ctrl: Self::Controller, input: #Input) -> #namespace::error::Result<#Output>;
}
},
);
quote! {
#doc
#[async_trait::async_trait]
#[auto_impl::auto_impl(&, Arc, Box)]
pub trait #Service {
type Controller: #namespace::controller::Controller;
#(#methods)*
async fn json_call_method(
&self,
ctrl: Self::Controller,
method: &str,
json: ::serde_json::Value,
) -> #namespace::error::Result<::serde_json::Value> {
match method {
#(#match_json_call_method)*
_ => Err(#namespace::error::Error::InvalidMethodIndex(0, method.to_string())),
}
}
}
}
}
fn impl_Service_for_Weak(&self) -> TokenStream {
let Self {
namespace,
Service,
methods,
..
} = self;
let methods = methods.iter().map(
|Method {
method,
Input,
Output,
..
}| {
quote! {
async fn #method(&self, ctrl: Self::Controller, input: #Input) -> #namespace::error::Result<#Output> {
let Some(service) = self.upgrade() else {
return Err(#namespace::error::Error::Shutdown);
};
service.#method(ctrl, input).await
}
}
},
);
quote! {
#[async_trait::async_trait]
impl<T> #Service for ::std::sync::Weak<T>
where
T: Send + Sync + 'static,
::std::sync::Arc<T>: #Service,
{
type Controller = <::std::sync::Arc<T> as #Service>::Controller;
#(#methods)*
}
}
}
fn struct_ServiceDescriptor(&self) -> TokenStream {
let Self {
namespace,
ServiceDescriptor,
ServiceMethodDescriptor,
Service_str,
Service_proto_str,
Service_package_str,
methods,
..
} = self;
let doc = format!("A service descriptor for a `{}`.", Service_str);
let methods = methods.iter().map(|Method { method_proto, .. }| {
quote! { #ServiceMethodDescriptor::#method_proto, }
});
quote! {
#[doc = #doc]
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Default)]
pub struct #ServiceDescriptor;
impl #namespace::descriptor::ServiceDescriptor for #ServiceDescriptor {
type Method = #ServiceMethodDescriptor;
fn name(&self) -> &'static str { #Service_str }
fn proto_name(&self) -> &'static str { #Service_proto_str }
fn package(&self) -> &'static str { #Service_package_str }
fn methods(&self) -> &'static [Self::Method] {
&[ #(#methods)* ]
}
}
}
}
fn enum_ServiceMethodDescriptor(&self) -> TokenStream {
let Self {
ServiceMethodDescriptor,
Service_str,
methods,
..
} = self;
let doc = formatdoc! {"
Methods available on a `{Service_str}`.
This can be used as a key when routing requests for servers/clients of a `{Service_str}`.
"};
let variants = methods.iter().map(
|Method {
method_proto,
index,
..
}| {
quote! { #method_proto = #index, }
},
);
let impl_MethodDescriptor = self.impl_MethodDescriptor_for_ServiceMethodDescriptor();
let impl_TryFrom = self.impl_TryFrom_for_ServiceMethodDescriptor();
quote! {
#[doc = #doc]
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
#[repr(u8)]
pub enum #ServiceMethodDescriptor {
#(#variants)*
}
#impl_MethodDescriptor
#impl_TryFrom
}
}
fn impl_MethodDescriptor_for_ServiceMethodDescriptor(&self) -> TokenStream {
let Self {
namespace,
ServiceMethodDescriptor,
methods,
..
} = self;
let name = {
let arms = methods.iter().map(
|Method {
method_proto,
method_str,
..
}| {
quote! { #ServiceMethodDescriptor::#method_proto => #method_str, }
},
);
quote! {
fn name(&self) -> &'static str {
match *self {
#(#arms)*
}
}
}
};
let proto_name = {
let arms = methods.iter().map(
|Method {
method_proto,
method_proto_str,
..
}| {
quote! { #ServiceMethodDescriptor::#method_proto => #method_proto_str, }
},
);
quote! {
fn proto_name(&self) -> &'static str {
match *self {
#(#arms)*
}
}
}
};
let input_type = {
let arms = methods.iter().map(|Method { method_proto, Input, .. }| {
quote! { #ServiceMethodDescriptor::#method_proto => ::std::any::TypeId::of::<#Input>(), }
});
quote! {
fn input_type(&self) -> ::std::any::TypeId {
match *self {
#(#arms)*
}
}
}
};
let input_proto_type = {
let arms = methods.iter().map(
|Method {
method_proto,
Input_proto_str,
..
}| {
quote! { #ServiceMethodDescriptor::#method_proto => #Input_proto_str, }
},
);
quote! {
fn input_proto_type(&self) -> &'static str {
match *self {
#(#arms)*
}
}
}
};
let output_type = {
let arms = methods.iter().map(|Method { method_proto, Output, .. }| {
quote! { #ServiceMethodDescriptor::#method_proto => ::std::any::TypeId::of::<#Output>(), }
});
quote! {
fn output_type(&self) -> ::std::any::TypeId {
match *self {
#(#arms)*
}
}
}
};
let output_proto_type = {
let arms = methods.iter().map(
|Method {
method_proto,
Output_proto_str,
..
}| {
quote! { #ServiceMethodDescriptor::#method_proto => #Output_proto_str, }
},
);
quote! {
fn output_proto_type(&self) -> &'static str {
match *self {
#(#arms)*
}
}
}
};
quote! {
impl #namespace::descriptor::MethodDescriptor for #ServiceMethodDescriptor {
#name
#proto_name
#input_type
#input_proto_type
#output_type
#output_proto_type
fn index(&self) -> u8 {
*self as u8
}
}
}
}
fn impl_TryFrom_for_ServiceMethodDescriptor(&self) -> TokenStream {
let Self {
namespace,
ServiceMethodDescriptor,
Service_str,
methods,
..
} = self;
let arms = methods.iter().map(
|Method {
method_proto,
index,
..
}| {
quote! { #index => Ok(#ServiceMethodDescriptor::#method_proto), }
},
);
quote! {
impl std::convert::TryFrom<u8> for #ServiceMethodDescriptor {
type Error = #namespace::error::Error;
fn try_from(value: u8) -> #namespace::error::Result<Self> {
match value {
#(#arms)*
_ => Err(#namespace::error::Error::InvalidMethodIndex(value, #Service_str.to_string())),
}
}
}
}
}
fn struct_ServiceClient(&self) -> TokenStream {
let Self {
namespace,
ServiceDescriptor,
ServiceClient,
Service_str,
..
} = self;
let doc = formatdoc! {"
A client for a `{Service_str}`.
This implements the `{Service_str}` trait by dispatching all method calls to the supplied `Handler`.
"};
let impl_service_client = self.impl_ServiceClient();
let impl_service_for_client = self.impl_Service_for_ServiceClient();
quote! {
#[doc = #doc]
#[derive(Clone, Debug)]
pub struct #ServiceClient<H>(H) where H: #namespace::handler::Handler;
impl<H> #ServiceClient<H> where H: #namespace::handler::Handler<Descriptor = #ServiceDescriptor> {
/// Creates a new client instance that delegates all method calls to the supplied handler.
pub fn new(handler: H) -> Self {
Self(handler)
}
}
#impl_service_client
#impl_service_for_client
}
}
fn impl_ServiceClient(&self) -> TokenStream {
let Self {
namespace,
ServiceClient,
ServiceDescriptor,
ServiceMethodDescriptor,
methods,
..
} = self;
let methods = methods.iter().map(
|Method {
method_inner,
method_proto,
Input,
Output,
..
}| {
quote! {
async fn #method_inner(handler: H, ctrl: H::Controller, input: #Input) -> #namespace::error::Result<#Output> {
#namespace::__rt::call_method(handler, ctrl, #ServiceMethodDescriptor::#method_proto, input).await
}
}
},
);
quote! {
impl<H> #ServiceClient<H> where H: #namespace::handler::Handler<Descriptor = #ServiceDescriptor> {
#(#methods)*
}
}
}
fn impl_Service_for_ServiceClient(&self) -> TokenStream {
let Self {
namespace,
Service,
ServiceClient,
ServiceDescriptor,
methods,
..
} = self;
let methods = methods.iter().map(
|Method {
method,
method_inner,
Input,
Output,
..
}| {
quote! {
async fn #method(&self, ctrl: H::Controller, input: #Input) -> #namespace::error::Result<#Output> {
#ServiceClient::#method_inner(self.0.clone(), ctrl, input).await
}
}
},
);
quote! {
#[async_trait::async_trait]
impl<H> #Service for #ServiceClient<H> where H: #namespace::handler::Handler<Descriptor = #ServiceDescriptor> {
type Controller = H::Controller;
#(#methods)*
}
}
}
fn struct_ServiceClientFactory(&self) -> TokenStream {
let Self {
namespace,
Service,
ServiceClient,
ServiceClientFactory,
ServiceDescriptor,
..
} = self;
quote! {
pub struct #ServiceClientFactory<C: #namespace::controller::Controller>(std::marker::PhantomData<C>);
impl<C: #namespace::controller::Controller> Clone for #ServiceClientFactory<C> {
fn clone(&self) -> Self {
Self(std::marker::PhantomData)
}
}
impl<C> #namespace::__rt::RpcClientFactory for #ServiceClientFactory<C> where C: #namespace::controller::Controller {
type Descriptor = #ServiceDescriptor;
type ClientImpl = Box<dyn #Service<Controller = C> + Send + Sync + 'static>;
type Controller = C;
fn new(handler: impl #namespace::handler::Handler<Descriptor = Self::Descriptor, Controller = Self::Controller>) -> Self::ClientImpl {
Box::new(#ServiceClient::new(handler))
}
}
}
}
fn struct_ServiceServer(&self) -> TokenStream {
let Self {
namespace,
Service,
ServiceDescriptor,
ServiceServer,
ServiceMethodDescriptor,
Service_str,
methods,
..
} = self;
let doc = formatdoc! {"
A server for a `{Service_str}`.
This implements the `Server` trait by handling requests and dispatch them to methods on the
supplied `{Service_str}`.
"};
let arms = methods.iter().map(
|Method {
method_proto,
method,
Input,
..
}| {
quote! {
#ServiceMethodDescriptor::#method_proto => {
let decoded: #Input = #namespace::__rt::decode(input)?;
let ret = service.#method(ctrl, decoded).await?;
#namespace::__rt::encode(ret)
}
}
},
);
quote! {
#[doc = #doc]
#[derive(Clone, Debug)]
pub struct #ServiceServer<A>(A) where A: #Service + Clone + Send + 'static;
impl<T> #ServiceServer<::std::sync::Weak<T>>
where
T: Send + Sync + 'static,
::std::sync::Arc<T>: #Service,
{
pub fn new_arc(service: ::std::sync::Arc<T>) -> #ServiceServer<::std::sync::Weak<T>> {
#ServiceServer(::std::sync::Arc::downgrade(&service))
}
}
impl<A> #ServiceServer<A> where A: #Service + Clone + Send + 'static {
/// Creates a new server instance that dispatches all calls to the supplied service.
pub fn new(service: A) -> #ServiceServer<A> {
#ServiceServer(service)
}
async fn call_inner(
service: A,
method: #ServiceMethodDescriptor,
ctrl: A::Controller,
input: ::bytes::Bytes)
-> #namespace::error::Result<::bytes::Bytes> {
match method {
#(#arms)*
}
}
}
#[async_trait::async_trait]
impl<A> #namespace::handler::Handler for #ServiceServer<A>
where
A: #Service + Clone + Send + Sync + 'static {
type Descriptor = #ServiceDescriptor;
type Controller = A::Controller;
async fn call(
&self,
ctrl: A::Controller,
method: #ServiceMethodDescriptor,
input: ::bytes::Bytes)
-> #namespace::error::Result<::bytes::Bytes> {
#ServiceServer::call_inner(self.0.clone(), method, ctrl, input).await
}
}
}
}
}
/// The service generator to be used with `prost-build` to generate RPC implementations for
/// `prost-simple-rpc`.
///
/// See the crate-level documentation for more info.
#[non_exhaustive]
#[derive(Debug, Default)]
pub struct ServiceGenerator;
impl prost_build::ServiceGenerator for ServiceGenerator {
fn generate(&mut self, service: prost_build::Service, buf: &mut String) {
let info = Service::new(service);
let trait_Service = info.trait_Service();
let impl_Service_for_Weak = info.impl_Service_for_Weak();
let struct_ServiceDescriptor = info.struct_ServiceDescriptor();
let enum_ServiceMethodDescriptor = info.enum_ServiceMethodDescriptor();
let struct_ServiceClient = info.struct_ServiceClient();
let struct_ServiceClientFactory = info.struct_ServiceClientFactory();
let struct_ServiceServer = info.struct_ServiceServer();
let tokens = quote! {
#trait_Service
#impl_Service_for_Weak
#struct_ServiceDescriptor
#enum_ServiceMethodDescriptor
#struct_ServiceClient
#struct_ServiceClientFactory
#struct_ServiceServer
};
buf.push('\n');
buf.push_str(&tokens.to_string());
buf.push('\n');
}
}
+8 -8
View File
@@ -12,9 +12,9 @@ core_clap:
仅用户名:--config-server admin,将使用官方的服务器
machine_id:
en: |+
the machine id to identify this machine, used for config recovery after disconnection, must be unique and fixed. default is from system.
the machine id to identify this machine, used for config recovery after disconnection, must be unique and fixed. by default it is loaded from persisted local state; on first start it may be migrated from system information or generated, then remains fixed.
zh-CN: |+
Web 配置服务器通过 machine id 来识别机器,用于断线重连后的配置恢复,需要保证唯一且固定不变。默认从系统获得
Web 配置服务器通过 machine id 来识别机器,用于断线重连后的配置恢复,需要保证唯一且固定不变。默认从本地持久化状态读取;首次启动时可能基于系统信息迁移或生成,之后保持固定不变
config_file:
en: "path to the config file, NOTE: the options set by cmdline args will override options in config file"
zh-CN: "配置文件路径,注意:命令行中的配置的选项会覆盖配置文件中的选项"
@@ -184,6 +184,9 @@ core_clap:
disable_upnp:
en: "disable runtime UPnP/NAT-PMP port mapping for eligible listeners; automatic port mapping is enabled by default"
zh-CN: "禁用符合条件监听器的运行时 UPnP/NAT-PMP 端口映射;自动端口映射默认开启"
enable_udp_broadcast_relay:
en: "Windows only: capture local UDP broadcast packets from physical interfaces and forward them to EasyTier peers. Helps games to find rooms in local network. Requires administrator privileges."
zh-CN: "仅 Windows:捕获物理网卡上的本机 UDP 广播包并转发给 EasyTier 对等节点,帮助局域网游戏发现房间。需要管理员权限。"
relay_all_peer_rpc:
en: "relay all peer rpc packets, even if the peer is not in the relay network whitelist. this can help peers not in relay network whitelist to establish p2p connection."
zh-CN: "转发所有对等节点的RPC数据包,即使对等节点不在转发网络白名单中。这可以帮助白名单外网络中的对等节点建立P2P连接。"
@@ -217,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 不同或缺失时会被拒绝。"
@@ -274,6 +271,9 @@ core_clap:
check_config:
en: Check config validity without starting the network
zh-CN: 检查配置文件的有效性并退出
daemon:
en: Run in daemon mode
zh-CN: 以守护进程模式运行
file_log_size_mb:
en: "per file log size in MB, default is 100MB"
zh-CN: "单个文件日志大小,单位 MB,默认值为 100MB"
+2 -3
View File
@@ -11,9 +11,8 @@ use windows::{
NET_FW_RULE_DIR_OUT,
},
Networking::WinSock::{
IP_UNICAST_IF, IPPROTO_IP, IPPROTO_IPV6, IPV6_UNICAST_IF, SIO_UDP_CONNRESET,
SO_EXCLUSIVEADDRUSE, SOCKET, SOCKET_ERROR, SOL_SOCKET, WSAGetLastError, WSAIoctl,
htonl, setsockopt,
IP_UNICAST_IF, IPPROTO_IP, IPPROTO_IPV6, IPV6_UNICAST_IF, SIO_UDP_CONNRESET, SOCKET,
SOCKET_ERROR, WSAGetLastError, WSAIoctl, htonl, setsockopt,
},
System::Com::{
CLSCTX_ALL, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx, CoUninitialize,
+219 -45
View File
@@ -1,36 +1,114 @@
use std::{
hash::Hasher,
net::{IpAddr, SocketAddr},
path::PathBuf,
sync::{Arc, Mutex},
};
use anyhow::Context;
use base64::{Engine as _, prelude::BASE64_STANDARD};
use clap::ValueEnum;
use clap::builder::PossibleValue;
use serde::{Deserialize, Serialize};
use strum::{Display, EnumString, VariantArray};
use tokio::io::AsyncReadExt as _;
use 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,
@@ -55,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,
@@ -65,13 +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,
}
}
@@ -151,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);
@@ -239,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>);
@@ -426,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>,
}
@@ -541,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>>,
@@ -562,6 +660,7 @@ 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>,
@@ -595,6 +694,10 @@ impl TomlConfigLoader {
Self::normalize_config_source(&mut config);
config.flags_struct = Some(Self::gen_flags(config.flags.clone().unwrap_or_default()));
if let Some(dns_resolvers) = &config.dns_resolvers {
dns::validate_dns_resolvers(dns_resolvers)
.with_context(|| "invalid dns_resolvers config")?;
}
let config = TomlConfigLoader {
config: Arc::new(Mutex::new(config)),
@@ -636,6 +739,21 @@ impl TomlConfigLoader {
}
}
impl DnsConfigLoaderExt for TomlConfigLoader {
cfg_select! {
feature = "magic-dns" => {
fn get_dns(&self) -> DnsConfig {
self.config.lock().unwrap().dns.clone()
}
fn set_dns(&self, config: DnsConfig) {
self.config.lock().unwrap().dns = config;
}
}
_ => {}
}
}
impl ConfigLoader for TomlConfigLoader {
fn get_inst_name(&self) -> String {
self.config
@@ -651,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>) {
@@ -998,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()
}
@@ -1060,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()
}
}
@@ -1291,6 +1420,51 @@ 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();
-2
View File
@@ -23,8 +23,6 @@ define_global_var!(MANUAL_CONNECTOR_RECONNECT_INTERVAL_MS, u64, 1000);
define_global_var!(OSPF_UPDATE_MY_GLOBAL_FOREIGN_NETWORK_INTERVAL_SEC, u64, 10);
define_global_var!(MACHINE_UID, Option<String>, None);
define_global_var!(MAX_DIRECT_CONNS_PER_PEER_IN_FOREIGN_NETWORK, u32, 3);
define_global_var!(DIRECT_CONNECT_TO_PUBLIC_SERVER, bool, true);
-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))]
);
}
}
}
+2 -1
View File
@@ -1,5 +1,4 @@
use std::{io, result};
use thiserror::Error;
use crate::tunnel;
@@ -55,4 +54,6 @@ pub enum Error {
pub type Result<T> = result::Result<T, Error>;
pub type ErrorCollection = crate::utils::error::ErrorCollection<Error>;
// impl From for std::
+60 -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)
@@ -77,6 +84,11 @@ pub enum GlobalCtxEvent {
ProxyCidrsUpdated(Vec<cidr::Ipv4Cidr>, Vec<cidr::Ipv4Cidr>), // (added, removed)
UdpBroadcastRelayStartResult {
capture_backend: Option<String>,
error: Option<String>,
},
CredentialChanged,
}
@@ -250,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(
@@ -282,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() {
@@ -486,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) {
@@ -781,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::{
+594
View File
@@ -0,0 +1,594 @@
use std::{
env,
ffi::OsString,
io::Write as _,
path::{Path, PathBuf},
time::{Duration, Instant},
};
use crate::utils::hostname;
use anyhow::Context as _;
#[cfg(unix)]
use nix::{
errno::Errno,
fcntl::{Flock, FlockArg},
};
#[derive(Debug, Clone, Default)]
pub struct MachineIdOptions {
pub explicit_machine_id: Option<String>,
pub state_dir: Option<PathBuf>,
}
pub fn resolve_machine_id(opts: &MachineIdOptions) -> anyhow::Result<uuid::Uuid> {
if let Some(explicit_machine_id) = opts.explicit_machine_id.as_deref() {
return Ok(parse_or_hash_machine_id(explicit_machine_id));
}
let state_file = resolve_machine_id_state_file(opts.state_dir.as_deref())?;
let allow_legacy_machine_uid_migration =
should_attempt_legacy_machine_uid_migration(&state_file);
if let Some(machine_id) = read_state_machine_id(&state_file)? {
return Ok(machine_id);
}
if let Some(machine_id) = read_legacy_machine_id_file() {
return persist_machine_id(&state_file, machine_id);
}
if allow_legacy_machine_uid_migration
&& let Some(machine_id) = resolve_legacy_machine_uid_hash()
{
return persist_machine_id(&state_file, machine_id);
}
let machine_id = resolve_new_machine_id().unwrap_or_else(uuid::Uuid::new_v4);
persist_machine_id(&state_file, machine_id)
}
fn parse_or_hash_machine_id(raw: &str) -> uuid::Uuid {
if let Ok(mid) = uuid::Uuid::parse_str(raw.trim()) {
return mid;
}
digest_uuid_from_str(raw)
}
fn digest_uuid_from_str(raw: &str) -> uuid::Uuid {
let mut b = [0u8; 16];
crate::tunnel::generate_digest_from_str("", raw, &mut b);
uuid::Uuid::from_bytes(b)
}
fn resolve_machine_id_state_file(state_dir: Option<&Path>) -> anyhow::Result<PathBuf> {
let state_dir = match state_dir {
Some(dir) => dir.to_path_buf(),
None => default_machine_id_state_dir()?,
};
Ok(state_dir.join("machine_id"))
}
fn non_empty_os_string(value: Option<OsString>) -> Option<OsString> {
value.filter(|value| !value.is_empty())
}
#[cfg(target_os = "linux")]
fn default_linux_machine_id_state_dir(
xdg_data_home: Option<OsString>,
home: Option<OsString>,
) -> PathBuf {
if let Some(path) = non_empty_os_string(xdg_data_home) {
return PathBuf::from(path).join("easytier");
}
if let Some(home) = non_empty_os_string(home) {
return PathBuf::from(home)
.join(".local")
.join("share")
.join("easytier");
}
PathBuf::from("/var/lib/easytier")
}
fn default_machine_id_state_dir() -> anyhow::Result<PathBuf> {
cfg_select! {
target_os = "linux" => Ok(default_linux_machine_id_state_dir(
env::var_os("XDG_DATA_HOME"),
env::var_os("HOME"),
)),
all(target_os = "macos", not(feature = "macos-ne")) => {
let home = non_empty_os_string(env::var_os("HOME"))
.ok_or_else(|| anyhow::anyhow!("HOME is not set, cannot resolve machine id state directory"))?;
Ok(PathBuf::from(home)
.join("Library")
.join("Application Support")
.join("com.easytier"))
},
target_os = "windows" => {
let local_app_data = non_empty_os_string(env::var_os("LOCALAPPDATA")).ok_or_else(|| {
anyhow::anyhow!("LOCALAPPDATA is not set, cannot resolve machine id state directory")
})?;
Ok(PathBuf::from(local_app_data).join("easytier"))
},
target_os = "freebsd" => {
let home = non_empty_os_string(env::var_os("HOME"))
.ok_or_else(|| anyhow::anyhow!("HOME is not set, cannot resolve machine id state directory"))?;
Ok(PathBuf::from(home).join(".local").join("share").join("easytier"))
},
target_os = "android" => {
anyhow::bail!("machine id state directory must be provided explicitly on Android");
},
_ => anyhow::bail!("machine id state directory is unsupported on this platform"),
}
}
fn read_state_machine_id(path: &Path) -> anyhow::Result<Option<uuid::Uuid>> {
let Some(contents) = read_optional_file(path)? else {
return Ok(None);
};
let machine_id = uuid::Uuid::parse_str(contents.trim())
.with_context(|| format!("invalid machine id in state file {}", path.display()))?;
Ok(Some(machine_id))
}
fn read_legacy_machine_id_file() -> Option<uuid::Uuid> {
let path = legacy_machine_id_file_path()?;
read_legacy_machine_id_file_at(&path)
}
fn read_legacy_machine_id_file_at(path: &Path) -> Option<uuid::Uuid> {
let contents = match std::fs::read_to_string(path) {
Ok(contents) => contents,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return None,
Err(err) => {
tracing::warn!(
path = %path.display(),
%err,
"ignoring unreadable legacy machine id file"
);
return None;
}
};
match uuid::Uuid::parse_str(contents.trim()) {
Ok(machine_id) => Some(machine_id),
Err(err) => {
tracing::warn!(
path = %path.display(),
%err,
"ignoring invalid legacy machine id file"
);
None
}
}
}
fn legacy_machine_id_file_path() -> Option<PathBuf> {
std::env::current_exe()
.ok()
.map(|path| path.with_file_name("et_machine_id"))
}
fn read_optional_file(path: &Path) -> anyhow::Result<Option<String>> {
match std::fs::read_to_string(path) {
Ok(contents) => Ok(Some(contents)),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(err).with_context(|| format!("failed to read {}", path.display())),
}
}
fn should_attempt_legacy_machine_uid_migration(state_file: &Path) -> bool {
let Some(state_dir) = state_file.parent() else {
return false;
};
let Ok(mut entries) = std::fs::read_dir(state_dir) else {
return false;
};
entries.any(|entry| entry.is_ok())
}
fn resolve_legacy_machine_uid_hash() -> Option<uuid::Uuid> {
machine_uid_seed().map(|seed| digest_uuid_from_str(seed.as_str()))
}
fn resolve_new_machine_id() -> Option<uuid::Uuid> {
let seed = machine_uid_seed()?;
#[cfg(target_os = "linux")]
{
let seed = linux_machine_id_seed(&seed);
Some(digest_uuid_from_str(&seed))
}
#[cfg(not(target_os = "linux"))]
{
Some(digest_uuid_from_str(&seed))
}
}
#[cfg(any(
target_os = "linux",
all(target_os = "macos", not(feature = "macos-ne")),
target_os = "windows",
target_os = "freebsd"
))]
fn machine_uid_seed() -> Option<String> {
machine_uid::get()
.ok()
.filter(|value| !value.trim().is_empty())
}
#[cfg(not(any(
target_os = "linux",
all(target_os = "macos", not(feature = "macos-ne")),
target_os = "windows",
target_os = "freebsd"
)))]
fn machine_uid_seed() -> Option<String> {
None
}
#[cfg(target_os = "linux")]
fn linux_machine_id_seed(machine_uid: &str) -> String {
let mut seed = format!("machine_uid={machine_uid}");
let hostname = hostname();
if !hostname.is_empty() {
seed.push_str("\nhostname=");
seed.push_str(&hostname);
}
let mac_addresses = collect_linux_mac_addresses();
if !mac_addresses.is_empty() {
seed.push_str("\nmacs=");
seed.push_str(&mac_addresses.join(","));
}
seed
}
#[cfg(target_os = "linux")]
fn collect_linux_mac_addresses() -> Vec<String> {
let mut macs = Vec::new();
let Ok(entries) = std::fs::read_dir("/sys/class/net") else {
return macs;
};
for entry in entries.flatten() {
let Ok(name) = entry.file_name().into_string() else {
continue;
};
if name == "lo" {
continue;
}
let address_path = entry.path().join("address");
let Ok(address) = std::fs::read_to_string(address_path) else {
continue;
};
let address = address.trim().to_ascii_lowercase();
if address.is_empty() || address == "00:00:00:00:00:00" {
continue;
}
macs.push(address);
}
macs.sort();
macs.dedup();
macs.truncate(3);
macs
}
fn persist_machine_id(path: &Path, machine_id: uuid::Uuid) -> anyhow::Result<uuid::Uuid> {
if let Some(existing) = read_state_machine_id(path)? {
return Ok(existing);
}
let _lock = MachineIdWriteLock::acquire(path)?;
if let Some(existing) = read_state_machine_id(path)? {
return Ok(existing);
}
write_uuid_file_atomically(path, machine_id)?;
Ok(machine_id)
}
fn write_uuid_file_atomically(path: &Path, machine_id: uuid::Uuid) -> anyhow::Result<()> {
let parent = path.parent().ok_or_else(|| {
anyhow::anyhow!(
"machine id state file {} has no parent directory",
path.display()
)
})?;
std::fs::create_dir_all(parent).with_context(|| {
format!(
"failed to create machine id state directory {}",
parent.display()
)
})?;
let tmp_path = parent.join(format!(
".machine_id.tmp-{}-{}",
std::process::id(),
uuid::Uuid::new_v4()
));
{
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp_path)
.with_context(|| format!("failed to create {}", tmp_path.display()))?;
file.write_all(machine_id.to_string().as_bytes())
.with_context(|| format!("failed to write {}", tmp_path.display()))?;
file.sync_all()
.with_context(|| format!("failed to flush {}", tmp_path.display()))?;
}
if let Err(err) = std::fs::rename(&tmp_path, path) {
let _ = std::fs::remove_file(&tmp_path);
return Err(err).with_context(|| {
format!(
"failed to move machine id state file into place at {}",
path.display()
)
});
}
Ok(())
}
struct MachineIdWriteLock {
#[cfg(unix)]
_lock: Flock<std::fs::File>,
#[cfg(not(unix))]
path: PathBuf,
}
impl MachineIdWriteLock {
fn acquire(path: &Path) -> anyhow::Result<Self> {
let parent = path.parent().ok_or_else(|| {
anyhow::anyhow!(
"machine id state file {} has no parent directory",
path.display()
)
})?;
std::fs::create_dir_all(parent).with_context(|| {
format!(
"failed to create machine id state directory {}",
parent.display()
)
})?;
#[cfg(unix)]
{
Self::acquire_unix(path)
}
#[cfg(not(unix))]
{
Self::acquire_fallback(path)
}
}
#[cfg(unix)]
fn acquire_unix(path: &Path) -> anyhow::Result<Self> {
let lock_path = path.with_extension("lock");
let deadline = Instant::now() + Duration::from_secs(5);
let mut lock_file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)
.with_context(|| format!("failed to open machine id lock {}", lock_path.display()))?;
loop {
match Flock::lock(lock_file, FlockArg::LockExclusiveNonblock) {
Ok(lock) => return Ok(Self { _lock: lock }),
Err((file, Errno::EAGAIN)) => {
if Instant::now() >= deadline {
anyhow::bail!(
"timed out waiting for machine id lock {}",
lock_path.display()
);
}
lock_file = file;
std::thread::sleep(Duration::from_millis(50));
}
Err((_file, err)) => {
anyhow::bail!(
"failed to acquire machine id lock {}: {}",
lock_path.display(),
err
);
}
}
}
}
#[cfg(not(unix))]
fn acquire_fallback(path: &Path) -> anyhow::Result<Self> {
let lock_path = path.with_extension("lock");
let deadline = Instant::now() + Duration::from_secs(5);
loop {
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&lock_path)
{
Ok(mut file) => {
writeln!(file, "pid={}", std::process::id()).ok();
return Ok(Self { path: lock_path });
}
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
if should_reap_stale_lock_file(&lock_path) {
let _ = std::fs::remove_file(&lock_path);
continue;
}
if Instant::now() >= deadline {
anyhow::bail!(
"timed out waiting for machine id lock {}",
lock_path.display()
);
}
std::thread::sleep(Duration::from_millis(50));
}
Err(err) => {
return Err(err).with_context(|| {
format!("failed to acquire machine id lock {}", lock_path.display())
});
}
}
}
}
}
#[cfg(not(unix))]
fn should_reap_stale_lock_file(lock_path: &Path) -> bool {
const STALE_LOCK_AGE: Duration = Duration::from_secs(30);
let Ok(metadata) = std::fs::metadata(lock_path) else {
return false;
};
let Ok(modified) = metadata.modified() else {
return false;
};
modified
.elapsed()
.is_ok_and(|elapsed| elapsed >= STALE_LOCK_AGE)
}
impl Drop for MachineIdWriteLock {
fn drop(&mut self) {
#[cfg(not(unix))]
let _ = std::fs::remove_file(&self.path);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resolve_machine_id_uses_uuid_seed_verbatim() {
let raw = "33333333-3333-3333-3333-333333333333".to_string();
let opts = MachineIdOptions {
explicit_machine_id: Some(raw.clone()),
state_dir: None,
};
assert_eq!(
resolve_machine_id(&opts).unwrap(),
uuid::Uuid::parse_str(&raw).unwrap()
);
}
#[test]
fn test_resolve_machine_id_reads_state_file() {
let temp_dir = tempfile::tempdir().unwrap();
let expected = uuid::Uuid::new_v4();
std::fs::write(temp_dir.path().join("machine_id"), expected.to_string()).unwrap();
let opts = MachineIdOptions {
explicit_machine_id: None,
state_dir: Some(temp_dir.path().to_path_buf()),
};
assert_eq!(resolve_machine_id(&opts).unwrap(), expected);
}
#[test]
fn test_read_legacy_machine_id_file_ignores_read_errors() {
let temp_dir = tempfile::tempdir().unwrap();
assert_eq!(read_legacy_machine_id_file_at(temp_dir.path()), None);
}
#[test]
fn test_write_uuid_file_atomically_writes_expected_contents() {
let temp_dir = tempfile::tempdir().unwrap();
let machine_id = uuid::Uuid::new_v4();
let state_file = temp_dir.path().join("machine_id");
write_uuid_file_atomically(&state_file, machine_id).unwrap();
assert_eq!(
std::fs::read_to_string(state_file).unwrap(),
machine_id.to_string()
);
}
#[test]
fn test_non_empty_os_string_filters_empty_values() {
assert_eq!(non_empty_os_string(Some(OsString::new())), None);
assert_eq!(
non_empty_os_string(Some(OsString::from("foo"))),
Some(OsString::from("foo"))
);
}
#[cfg(target_os = "linux")]
#[test]
fn test_default_linux_machine_id_state_dir_falls_back_in_order() {
assert_eq!(
default_linux_machine_id_state_dir(
Some(OsString::from("/tmp/xdg")),
Some(OsString::from("/tmp/home"))
),
PathBuf::from("/tmp/xdg").join("easytier")
);
assert_eq!(
default_linux_machine_id_state_dir(
Some(OsString::new()),
Some(OsString::from("/tmp/home"))
),
PathBuf::from("/tmp/home")
.join(".local")
.join("share")
.join("easytier")
);
assert_eq!(
default_linux_machine_id_state_dir(Some(OsString::new()), Some(OsString::new())),
PathBuf::from("/var/lib/easytier")
);
}
#[test]
fn test_persist_machine_id_creates_missing_state_dir() {
let temp_dir = tempfile::tempdir().unwrap();
let state_file = temp_dir.path().join("nested").join("machine_id");
let machine_id = uuid::Uuid::new_v4();
assert_eq!(
persist_machine_id(&state_file, machine_id).unwrap(),
machine_id
);
assert_eq!(
std::fs::read_to_string(state_file).unwrap(),
machine_id.to_string()
);
}
#[test]
fn test_legacy_machine_uid_migration_requires_existing_state_dir_content() {
let temp_dir = tempfile::tempdir().unwrap();
let missing_state_file = temp_dir.path().join("missing").join("machine_id");
assert!(!should_attempt_legacy_machine_uid_migration(
&missing_state_file
));
let empty_dir = temp_dir.path().join("empty");
std::fs::create_dir_all(&empty_dir).unwrap();
assert!(!should_attempt_legacy_machine_uid_migration(
&empty_dir.join("machine_id")
));
std::fs::write(empty_dir.join("config.toml"), "x=1").unwrap();
assert!(should_attempt_legacy_machine_uid_migration(
&empty_dir.join("machine_id")
));
}
}
+3 -77
View File
@@ -1,26 +1,23 @@
use std::{
fmt::Debug,
future,
io::Write as _,
sync::{Arc, Mutex},
};
use time::util::refresh_tz;
use tokio::{task::JoinSet, time::timeout};
use tracing::Instrument;
use crate::{set_global_var, use_global_var};
pub mod acl_processor;
pub mod compressor;
pub mod config;
pub mod constants;
pub mod dns;
pub mod env_parser;
pub mod error;
pub mod global_ctx;
pub mod idn;
pub mod ifcfg;
pub mod log;
pub mod machine_id;
pub mod netns;
pub mod network;
pub mod os_info;
@@ -31,6 +28,8 @@ pub mod token_bucket;
pub mod tracing_rolling_appender;
pub mod upnp;
pub use machine_id::{MachineIdOptions, resolve_machine_id};
pub fn get_logger_timer<F: time::formatting::Formattable>(
format: F,
) -> tracing_subscriber::fmt::time::OffsetTime<F> {
@@ -96,71 +95,6 @@ pub fn join_joinset_background<T: Debug + Send + Sync + 'static>(
);
}
pub fn set_default_machine_id(mid: Option<String>) {
set_global_var!(MACHINE_UID, mid);
}
pub fn get_machine_id() -> uuid::Uuid {
if let Some(default_mid) = use_global_var!(MACHINE_UID) {
if let Ok(mid) = uuid::Uuid::parse_str(default_mid.trim()) {
return mid;
}
let mut b = [0u8; 16];
crate::tunnel::generate_digest_from_str("", &default_mid, &mut b);
return uuid::Uuid::from_bytes(b);
}
// a path same as the binary
let machine_id_file = std::env::current_exe()
.map(|x| x.with_file_name("et_machine_id"))
.unwrap_or_else(|_| std::path::PathBuf::from("et_machine_id"));
// try load from local file
if let Ok(mid) = std::fs::read_to_string(&machine_id_file)
&& let Ok(mid) = uuid::Uuid::parse_str(mid.trim())
{
return mid;
}
#[cfg(any(
target_os = "linux",
all(target_os = "macos", not(feature = "macos-ne")),
target_os = "windows",
target_os = "freebsd"
))]
let gen_mid = machine_uid::get()
.map(|x| {
if x.is_empty() {
return uuid::Uuid::new_v4();
}
let mut b = [0u8; 16];
crate::tunnel::generate_digest_from_str("", x.as_str(), &mut b);
uuid::Uuid::from_bytes(b)
})
.ok();
#[cfg(not(any(
target_os = "linux",
all(target_os = "macos", not(feature = "macos-ne")),
target_os = "windows",
target_os = "freebsd"
)))]
let gen_mid = None;
if let Some(mid) = gen_mid {
return mid;
}
let gen_mid = uuid::Uuid::new_v4();
// try save to local file
if let Ok(mut file) = std::fs::File::create(machine_id_file) {
let _ = file.write_all(gen_mid.to_string().as_bytes());
}
gen_mid
}
pub fn shrink_dashmap<K: Eq + std::hash::Hash, V>(
map: &dashmap::DashMap<K, V>,
threshold: Option<usize>,
@@ -210,12 +144,4 @@ mod tests {
assert_eq!(weak_js.weak_count(), 0);
assert_eq!(weak_js.strong_count(), 0);
}
#[test]
fn test_get_machine_id_uses_uuid_seed_verbatim() {
let raw = "33333333-3333-3333-3333-333333333333".to_string();
set_default_machine_id(Some(raw.clone()));
assert_eq!(get_machine_id(), uuid::Uuid::parse_str(&raw).unwrap());
set_default_machine_id(None);
}
}
+22
View File
@@ -85,6 +85,15 @@ pub enum MetricName {
/// Traffic packets forwarded for foreign network, forward
TrafficPacketsForeignForwardForwarded,
/// UDP broadcast relay packets captured from the raw socket
UdpBroadcastRelayPacketsCaptured,
/// UDP broadcast relay packets ignored before forwarding
UdpBroadcastRelayPacketsIgnored,
/// UDP broadcast relay packets forwarded
UdpBroadcastRelayPacketsForwarded,
/// UDP broadcast relay packets that failed to forward
UdpBroadcastRelayPacketsForwardFailed,
/// Compression bytes before compression
CompressionBytesRxBefore,
/// Compression bytes after compression
@@ -167,6 +176,19 @@ impl fmt::Display for MetricName {
write!(f, "traffic_packets_foreign_forward_forwarded")
}
MetricName::UdpBroadcastRelayPacketsCaptured => {
write!(f, "udp_broadcast_relay_packets_captured")
}
MetricName::UdpBroadcastRelayPacketsIgnored => {
write!(f, "udp_broadcast_relay_packets_ignored")
}
MetricName::UdpBroadcastRelayPacketsForwarded => {
write!(f, "udp_broadcast_relay_packets_forwarded")
}
MetricName::UdpBroadcastRelayPacketsForwardFailed => {
write!(f, "udp_broadcast_relay_packets_forward_failed")
}
MetricName::CompressionBytesRxBefore => write!(f, "compression_bytes_rx_before"),
MetricName::CompressionBytesRxAfter => write!(f, "compression_bytes_rx_after"),
MetricName::CompressionBytesTxBefore => write!(f, "compression_bytes_tx_before"),
+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 {
+165 -55
View File
@@ -1,13 +1,15 @@
use std::{
collections::BTreeSet,
future::Future,
sync::{Arc, Weak},
time::{Duration, Instant},
};
use dashmap::DashSet;
use tokio::{sync::mpsc, task::JoinSet, time::timeout};
use crate::{
common::{PeerId, dns::socket_addrs, join_joinset_background},
common::{PeerId, join_joinset_background},
peers::peer_conn::PeerConnId,
proto::{
api::instance::{
@@ -16,10 +18,12 @@ use crate::{
},
rpc_types::{self, controller::BaseController},
},
tunnel::{IpVersion, TunnelConnector},
tunnel::{IpVersion, TunnelConnector, TunnelScheme, matches_scheme},
utils::weak_upgrade,
};
use super::create_connector_by_url;
use crate::utils::dns::socket_addrs;
use crate::{
common::{
error::Error,
@@ -30,8 +34,6 @@ use crate::{
use_global_var,
};
use super::create_connector_by_url;
type ConnectorMap = Arc<DashSet<url::Url>>;
#[derive(Debug, Clone)]
@@ -83,6 +85,55 @@ impl ManualConnectorManager {
ret
}
fn reconnect_timeout(dead_url: &url::Url) -> Duration {
let use_long_timeout = matches_scheme!(
dead_url,
TunnelScheme::Http | TunnelScheme::Https | TunnelScheme::Txt | TunnelScheme::Srv
) || matches!(dead_url.scheme(), "ws" | "wss");
Duration::from_secs(if use_long_timeout { 20 } else { 2 })
}
fn remaining_budget(started_at: Instant, total_timeout: Duration) -> Option<Duration> {
let remaining = total_timeout.checked_sub(started_at.elapsed())?;
(!remaining.is_zero()).then_some(remaining)
}
fn emit_connect_error(
data: &ConnectorManagerData,
dead_url: &url::Url,
ip_version: IpVersion,
error: &Error,
) {
data.global_ctx.issue_event(GlobalCtxEvent::ConnectError(
dead_url.to_string(),
format!("{:?}", ip_version),
format!("{:#?}", error),
));
}
fn reconnect_timeout_error(stage: &str, duration: Duration) -> Error {
Error::AnyhowError(anyhow::anyhow!("{} timeout after {:?}", stage, duration))
}
async fn with_reconnect_timeout<T, F>(
stage: &'static str,
started_at: Instant,
total_timeout: Duration,
fut: F,
) -> Result<T, Error>
where
F: Future<Output = Result<T, Error>>,
{
let remaining = Self::remaining_budget(started_at, total_timeout)
.ok_or_else(|| Self::reconnect_timeout_error(stage, started_at.elapsed()))?;
timeout(remaining, fut)
.await
.map_err(|_| Self::reconnect_timeout_error(stage, remaining))?
}
}
impl ManualConnectorManager {
pub fn add_connector<T>(&self, connector: T)
where
T: TunnelConnector + 'static,
@@ -242,11 +293,18 @@ impl ManualConnectorManager {
async fn conn_reconnect_with_ip_version(
data: Arc<ConnectorManagerData>,
dead_url: String,
dead_url: url::Url,
ip_version: IpVersion,
started_at: Instant,
total_timeout: Duration,
) -> Result<ReconnResult, Error> {
let connector =
create_connector_by_url(&dead_url, &data.global_ctx.clone(), ip_version).await?;
let connector = Self::with_reconnect_timeout(
"resolve",
started_at,
total_timeout,
create_connector_by_url(dead_url.as_str(), &data.global_ctx, ip_version),
)
.await?;
data.global_ctx
.issue_event(GlobalCtxEvent::Connecting(connector.remote_url()));
@@ -257,10 +315,25 @@ impl ManualConnectorManager {
)));
};
let (peer_id, conn_id) = pm.try_direct_connect(connector).await?;
let tunnel = Self::with_reconnect_timeout(
"connect",
started_at,
total_timeout,
pm.connect_tunnel(connector),
)
.await?;
let (peer_id, conn_id) = Self::with_reconnect_timeout(
"handshake",
started_at,
total_timeout,
pm.add_client_tunnel_with_peer_id_hint(tunnel, true, None),
)
.await?;
tracing::info!("reconnect succ: {} {} {}", peer_id, conn_id, dead_url);
Ok(ReconnResult {
dead_url,
dead_url: dead_url.to_string(),
peer_id,
conn_id,
})
@@ -273,22 +346,33 @@ impl ManualConnectorManager {
tracing::info!("reconnect: {}", dead_url);
let mut ip_versions = vec![];
if dead_url.scheme() == "ring" || dead_url.scheme() == "txt" || dead_url.scheme() == "srv" {
if matches_scheme!(
dead_url,
TunnelScheme::Ring | TunnelScheme::Txt | TunnelScheme::Srv
) {
ip_versions.push(IpVersion::Both);
} else {
let converted_dead_url = crate::common::idn::convert_idn_to_ascii(dead_url.clone())?;
let addrs = match socket_addrs(&converted_dead_url, || Some(1000)).await {
let converted_dead_url =
match crate::common::idn::convert_idn_to_ascii(dead_url.clone()) {
Ok(url) => url,
Err(error) => {
let error: Error = error.into();
Self::emit_connect_error(&data, &dead_url, IpVersion::Both, &error);
return Err(error);
}
};
let addrs = match Self::with_reconnect_timeout(
"resolve",
Instant::now(),
Self::reconnect_timeout(&dead_url),
socket_addrs(&converted_dead_url, || Some(1000)),
)
.await
{
Ok(addrs) => addrs,
Err(e) => {
data.global_ctx.issue_event(GlobalCtxEvent::ConnectError(
dead_url.to_string(),
format!("{:?}", IpVersion::Both),
format!("{:?}", e),
));
return Err(Error::AnyhowError(anyhow::anyhow!(
"get ip from url failed: {:?}",
e
)));
Err(error) => {
Self::emit_connect_error(&data, &dead_url, IpVersion::Both, &error);
return Err(error);
}
};
tracing::info!(?addrs, ?dead_url, "get ip from url done");
@@ -313,46 +397,24 @@ impl ManualConnectorManager {
"cannot get ip from url"
)));
for ip_version in ip_versions {
let use_long_timeout = dead_url.scheme() == "http"
|| dead_url.scheme() == "https"
|| dead_url.scheme() == "ws"
|| dead_url.scheme() == "wss"
|| dead_url.scheme() == "txt"
|| dead_url.scheme() == "srv";
let ret = timeout(
// allow http/websocket connector to wait longer
std::time::Duration::from_secs(if use_long_timeout { 20 } else { 2 }),
Self::conn_reconnect_with_ip_version(
data.clone(),
dead_url.to_string(),
ip_version,
),
let started_at = Instant::now();
let ret = Self::conn_reconnect_with_ip_version(
data.clone(),
dead_url.clone(),
ip_version,
started_at,
Self::reconnect_timeout(&dead_url),
)
.await;
tracing::info!("reconnect: {} done, ret: {:?}", dead_url, ret);
match ret {
Ok(Ok(_)) => {
// 外层和内层都成功:解包并跳出
reconn_ret = ret.unwrap();
break;
}
Ok(Err(e)) => {
// 外层成功,内层失败
reconn_ret = Err(e);
}
Err(e) => {
// 外层失败
reconn_ret = Err(e.into());
Ok(result) => return Ok(result),
Err(error) => {
Self::emit_connect_error(&data, &dead_url, ip_version, &error);
reconn_ret = Err(error);
}
}
// 发送事件(只有在未 break 时才执行)
data.global_ctx.issue_event(GlobalCtxEvent::ConnectError(
dead_url.to_string(),
format!("{:?}", ip_version),
format!("{:?}", reconn_ret),
));
}
reconn_ret
@@ -388,6 +450,54 @@ mod tests {
use super::*;
#[tokio::test]
async fn reconnect_timeout_reports_exhausted_budget_for_stage() {
let started_at = Instant::now() - Duration::from_millis(50);
let err = ManualConnectorManager::with_reconnect_timeout(
"resolve",
started_at,
Duration::from_millis(1),
async { Ok::<(), Error>(()) },
)
.await
.unwrap_err();
let message = err.to_string();
assert!(message.contains("resolve timeout after"));
}
#[tokio::test]
async fn reconnect_timeout_reports_stage_timeout_with_remaining_budget() {
let err = ManualConnectorManager::with_reconnect_timeout(
"handshake",
Instant::now(),
Duration::from_millis(10),
async {
tokio::time::sleep(Duration::from_millis(50)).await;
Ok::<(), Error>(())
},
)
.await
.unwrap_err();
let message = err.to_string();
assert!(message.contains("handshake timeout after"));
}
#[tokio::test]
async fn reconnect_timeout_preserves_success_within_budget() {
let result = ManualConnectorManager::with_reconnect_timeout(
"connect",
Instant::now(),
Duration::from_millis(50),
async { Ok::<_, Error>(123_u32) },
)
.await
.unwrap();
assert_eq!(result, 123);
}
#[tokio::test]
async fn test_reconnect_with_connecting_addr() {
set_global_var!(MANUAL_CONNECTOR_RECONNECT_INTERVAL_MS, 1);
+2 -2
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;
+16 -19
View File
@@ -484,6 +484,15 @@ struct NetworkOptions {
)]
disable_upnp: Option<bool>,
#[arg(
long,
env = "ET_ENABLE_UDP_BROADCAST_RELAY",
help = t!("core_clap.enable_udp_broadcast_relay").to_string(),
num_args = 0..=1,
default_missing_value = "true"
)]
enable_udp_broadcast_relay: Option<bool>,
#[arg(
long,
env = "ET_RELAY_ALL_PEER_RPC",
@@ -569,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",
@@ -1121,7 +1117,6 @@ impl NetworkOptions {
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
@@ -1142,10 +1137,9 @@ impl NetworkOptions {
.disable_sym_hole_punching
.unwrap_or(f.disable_sym_hole_punching);
f.disable_upnp = self.disable_upnp.unwrap_or(f.disable_upnp);
// 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();
}
f.enable_udp_broadcast_relay = self
.enable_udp_broadcast_relay
.unwrap_or(f.enable_udp_broadcast_relay);
cfg.set_flags(f);
if !self.exit_nodes.is_empty() {
@@ -1336,7 +1330,10 @@ async fn run_main(cli: Cli) -> anyhow::Result<()> {
let _web_client = if let Some(config_server_url_s) = cli.config_server.as_ref() {
let wc = web_client::run_web_client(
config_server_url_s,
cli.machine_id.clone(),
crate::common::MachineIdOptions {
explicit_machine_id: cli.machine_id.clone(),
state_dir: None,
},
cli.network_options.hostname.clone(),
cli.network_options.secure_mode.unwrap_or(false),
manager.clone(),
+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();
}
}
@@ -6,13 +6,13 @@ use std::{
path::Path,
};
use super::{OSConfig, SystemConfig};
use super::{SystemConfig, SystemConfigurator};
const MAC_RESOLVER_FILE_HEADER: &str = "# Added by easytier\n";
const ETC_RESOLVER: &str = "/etc/resolver";
const ETC_RESOLV_CONF: &str = "/etc/resolv.conf";
#[derive(Default)]
#[derive(Clone, Default)]
pub struct DarwinConfigurator {}
impl DarwinConfigurator {
pub fn new() -> Self {
@@ -27,7 +27,7 @@ impl DarwinConfigurator {
true
}
pub fn do_set_dns(&self, cfg: &OSConfig) -> io::Result<()> {
pub fn do_set_dns(&self, cfg: &SystemConfig) -> io::Result<()> {
fs::create_dir_all(ETC_RESOLVER)?;
let mut keep = HashSet::new();
@@ -104,12 +104,12 @@ impl DarwinConfigurator {
}
}
impl SystemConfig for DarwinConfigurator {
fn set_dns(&self, cfg: &OSConfig) -> io::Result<()> {
impl SystemConfigurator for DarwinConfigurator {
fn set_dns(&self, cfg: &SystemConfig) -> io::Result<()> {
self.do_set_dns(cfg)
}
fn close(&self) -> io::Result<()> {
fn clean(&self) -> io::Result<()> {
self.do_close()
}
}
+42
View File
@@ -0,0 +1,42 @@
#[allow(unused_imports)]
use crate::utils::BoxExt;
#[cfg(target_os = "linux")]
pub mod linux;
#[cfg(target_os = "windows")]
pub mod windows;
#[cfg(all(target_os = "macos", not(feature = "macos-ne")))]
pub mod macos;
#[derive(Default, Debug)]
pub struct SystemConfig {
pub nameservers: Vec<String>,
pub search_domains: Vec<String>,
pub match_domains: Vec<String>,
}
pub trait SystemConfigurator: Send + Sync {
fn set_dns(&self, cfg: &SystemConfig) -> std::io::Result<()>;
fn clean(&self) -> std::io::Result<()>;
}
// TODO: move this to nic mod
pub fn get(
#[allow(unused_variables)] interface: &str,
) -> Result<Option<Box<dyn SystemConfigurator>>, anyhow::Error> {
cfg_select! {
target_os = "windows" => {
use crate::dns::system::windows::WindowsDNSManager;
Ok(Some(WindowsDNSManager::new(interface)?.boxed()))
}
all(target_os = "macos", not(feature = "macos-ne")) => {
use crate::dns::system::macos::DarwinConfigurator;
Ok(Some(DarwinConfigurator::new().boxed()))
}
_ => Ok(None)
}
}
@@ -6,7 +6,7 @@ use winreg::RegKey;
use crate::common::ifcfg::RegistryManager;
use super::{OSConfig, SystemConfig};
use super::{SystemConfig, SystemConfigurator};
pub fn is_windows_10_or_better() -> io::Result<bool> {
let hklm = winreg::enums::HKEY_LOCAL_MACHINE;
@@ -19,6 +19,7 @@ pub fn is_windows_10_or_better() -> io::Result<bool> {
}
// 假设 interface_guid 是你的网络接口 GUID
#[derive(Clone)]
pub struct InterfaceControl {
interface_guid: String,
}
@@ -125,6 +126,7 @@ impl InterfaceControl {
}
}
#[derive(Clone)]
pub struct WindowsDNSManager {
tun_dev_name: String,
interface_control: InterfaceControl,
@@ -146,8 +148,8 @@ impl WindowsDNSManager {
}
}
impl SystemConfig for WindowsDNSManager {
fn set_dns(&self, cfg: &OSConfig) -> io::Result<()> {
impl SystemConfigurator for WindowsDNSManager {
fn set_dns(&self, cfg: &SystemConfig) -> io::Result<()> {
self.set_primary_dns(
&cfg.nameservers
.iter()
@@ -158,44 +160,48 @@ impl SystemConfig for WindowsDNSManager {
Ok(())
}
fn close(&self) -> io::Result<()> {
fn clean(&self) -> io::Result<()> {
Ok(())
}
}
#[cfg(test)]
#[cfg(all(test, target_os = "windows", feature = "magic-dns", feature = "tun"))]
mod tests {
use cidr::Ipv4Inet;
use std::net::IpAddr;
#[cfg(target_os = "windows")]
#[tokio::test]
async fn test_windows_set_primary_server() {
use std::{net::Ipv4Addr, str::FromStr as _, time::Duration};
use std::{str::FromStr as _, sync::Arc, time::Duration};
use tokio_util::sync::CancellationToken;
use crate::instance::dns_server::{
runner::DnsRunner,
tests::{check_dns_record, prepare_env},
use crate::dns::{
config::DNS_DEFAULT_ADDRESSES,
tests::{prepare_env, start_dns_node},
};
use crate::instance::proxy_cidrs_monitor::ProxyCidrsMonitor;
use crate::instance::virtual_nic::NicCtx;
use crate::peers::peer_manager::PeerManager;
let tun_ip = Ipv4Inet::from_str("10.144.144.10/24").unwrap();
let (peer_mgr, virtual_nic) = prepare_env("test1", tun_ip).await;
let (peer_mgr, virtual_nic): (Arc<PeerManager>, NicCtx) =
prepare_env("test1", tun_ip).await;
let tun_name = virtual_nic.ifname().await.unwrap();
// prepare_env does not run full Instance::run, so start the monitor explicitly in test.
let _monitor = ProxyCidrsMonitor::new(peer_mgr.clone(), peer_mgr.get_global_ctx()).start();
let mut dns_node = start_dns_node(peer_mgr, virtual_nic);
println!("dev_name: {}", tun_name);
let fake_ip = Ipv4Addr::from_str("100.100.100.101").unwrap();
let mut dns_runner = DnsRunner::new(peer_mgr, Some(tun_name.clone()), tun_ip, fake_ip);
let fake_ip = match DNS_DEFAULT_ADDRESSES[0].addr.ip() {
IpAddr::V4(ip) => ip,
IpAddr::V6(ip) => panic!("unexpected ipv6 default dns address in test: {ip}"),
};
let cancel_token = CancellationToken::new();
let cancel_token_clone = cancel_token.clone();
let t = tokio::spawn(async move {
dns_runner.run(cancel_token_clone).await;
});
// windows is slow to add a ip address, wait for a longer time for dns server ready ,with ping
// Windows may take a while to attach the test IP; wait until ping succeeds.
let now = std::time::Instant::now();
while now.elapsed() < Duration::from_secs(15) {
let mut ping_ready = false;
while now.elapsed() < Duration::from_secs(5) {
tokio::time::sleep(Duration::from_secs(1)).await;
if let Ok(o) = tokio::process::Command::new("ping")
.arg("-n")
@@ -207,11 +213,49 @@ mod tests {
.await
&& o.status.success()
{
ping_ready = true;
break;
}
}
if !ping_ready {
tracing::warn!(
"dns test endpoint {} did not respond to ping in time; continue with dns checks",
fake_ip
);
}
check_dns_record(&fake_ip, "test1.et.net", "10.144.144.10").await;
// First verify the DNS node can answer queries when explicitly targeting it.
let direct_lookup_deadline = std::time::Instant::now() + Duration::from_secs(3);
let mut direct_lookup_ok = false;
let mut last_direct_output = String::new();
while std::time::Instant::now() < direct_lookup_deadline {
let result = tokio::time::timeout(
Duration::from_secs(3),
tokio::process::Command::new("nslookup")
.arg("test1.et.net")
.arg(fake_ip.to_string())
.output(),
)
.await;
if let Ok(Ok(ret)) = result {
let output = String::from_utf8_lossy(&ret.stdout).to_string();
println!("direct nslookup output: {}", output);
if ret.status.success() && output.contains("10.144.144.10") {
direct_lookup_ok = true;
break;
}
last_direct_output = output;
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
assert!(
direct_lookup_ok,
"direct nslookup against dns server did not return expected IP in time; last output: {last_direct_output}"
);
let dns_mgr = super::WindowsDNSManager::new(&tun_name).unwrap();
println!("dev_name: {}", tun_name);
@@ -219,27 +263,44 @@ mod tests {
dns_mgr
.interface_control
.set_primary_dns(
&["100.100.100.101".parse().unwrap()],
&[".et.net.".to_string()],
)
.set_primary_dns(&[fake_ip.into()], &[".et.net.".to_string()])
.unwrap();
dns_mgr.interface_control.flush_dns().unwrap();
tracing::info!("check dns record with nslookup");
// nslookup should return 10.144.144.10
let ret = tokio::process::Command::new("nslookup")
.arg("test1.et.net")
.output()
.await
.expect("failed to execute process");
assert!(ret.status.success());
let output = String::from_utf8_lossy(&ret.stdout);
println!("nslookup output: {}", output);
assert!(output.contains("10.144.144.10"));
// nslookup should eventually return 10.144.144.10 after system DNS setting propagation.
let lookup_deadline = std::time::Instant::now() + Duration::from_secs(30);
let mut lookup_ok = false;
let mut last_output = String::new();
cancel_token.cancel();
let _ = t.await;
while std::time::Instant::now() < lookup_deadline {
let result = tokio::time::timeout(
Duration::from_secs(3),
tokio::process::Command::new("nslookup")
.arg("test1.et.net")
.output(),
)
.await;
if let Ok(Ok(ret)) = result {
let output = String::from_utf8_lossy(&ret.stdout).to_string();
println!("nslookup output: {}", output);
if ret.status.success() && output.contains("10.144.144.10") {
lookup_ok = true;
break;
}
last_output = output;
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
assert!(
lookup_ok,
"nslookup did not return expected IP in time; last output: {last_output}"
);
dns_node.stop().await.unwrap();
}
}
+984
View File
@@ -0,0 +1,984 @@
#![cfg(all(feature = "magic-dns", feature = "tun"))]
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4};
use std::str::FromStr as _;
use std::sync::Arc;
use std::time::{Duration, Instant};
use crate::common::config::TomlConfigLoader;
use crate::common::global_ctx::GlobalCtx;
use crate::common::global_ctx::tests::get_mock_global_ctx;
use crate::connector::udp_hole_punch::tests::replace_stun_info_collector;
use crate::dns::node::DnsNode;
use crate::dns::peer_mgr::DnsPeerMgr;
use crate::instance::instance::ArcNicCtx;
use crate::instance::virtual_nic::NicCtx;
use crate::peers::create_packet_recv_chan;
use crate::peers::peer_manager::{PeerManager, RouteAlgoType};
use crate::peers::tests::{connect_peer_manager, wait_route_appear};
use crate::proto::common::{NatType, Url};
use crate::proto::dns::{DnsSnapshot, HeartbeatRequest, ZoneData};
use cidr::Ipv4Inet;
use hickory_net::client::{Client, ClientHandle};
use hickory_net::runtime::TokioRuntimeProvider;
use hickory_net::udp::UdpClientStream;
use hickory_net::xfer::Protocol;
use hickory_proto::op::{Message, MessageType, OpCode, Query};
use hickory_proto::rr;
use hickory_proto::rr::{DNSClass, Name, RData, RecordType};
use hickory_proto::serialize::binary::{BinEncodable, BinEncoder};
use hickory_server::server::Request;
use maplit::hashset;
use tokio::sync::Notify;
use uuid::Uuid;
// TODO: move to system::tests
pub async fn prepare_env(dns_name: &str, tun_ip: Ipv4Inet) -> (Arc<PeerManager>, NicCtx) {
prepare_env_with_tld_dns_zone(dns_name, tun_ip, None).await
}
pub async fn prepare_env_with_tld_dns_zone(
dns_name: &str,
tun_ip: Ipv4Inet,
tld_dns_zone: Option<&str>,
) -> (Arc<PeerManager>, NicCtx) {
let ctx = get_mock_global_ctx();
ctx.set_hostname(dns_name.to_owned());
ctx.set_ipv4(Some(tun_ip));
let mut dns_config = ctx.config.get_dns().into_raw();
dns_config.name = Some(dns_name.parse().unwrap());
if let Some(zone) = tld_dns_zone {
dns_config.domain = Some(zone.parse().expect("invalid test dns zone"));
}
ctx.config.set_dns(dns_config.into());
let (s, r) = create_packet_recv_chan();
let peer_mgr = Arc::new(PeerManager::new(RouteAlgoType::Ospf, ctx, s));
peer_mgr.run().await.unwrap();
replace_stun_info_collector(peer_mgr.clone(), NatType::PortRestricted);
let r = Arc::new(tokio::sync::Mutex::new(r));
let mut virtual_nic = NicCtx::new(
peer_mgr.get_global_ctx(),
&peer_mgr,
r,
Arc::new(Notify::new()),
);
virtual_nic.run(Some(tun_ip), None).await.unwrap();
(peer_mgr, virtual_nic)
}
pub fn start_dns_node(peer_mgr: Arc<PeerManager>, virtual_nic: NicCtx) -> DnsNode {
let global_ctx = peer_mgr.get_global_ctx();
let nic_ctx: ArcNicCtx = Arc::new(tokio::sync::Mutex::new(Some(Box::new(virtual_nic))));
let mut node = DnsNode::new(peer_mgr, global_ctx, nic_ctx);
node.start();
node
}
pub fn start_dns_node_without_nic(peer_mgr: Arc<PeerManager>) -> DnsNode {
let global_ctx = peer_mgr.get_global_ctx();
let nic_ctx: ArcNicCtx = Arc::new(tokio::sync::Mutex::new(None));
let mut node = DnsNode::new(peer_mgr, global_ctx, nic_ctx);
node.start();
node
}
pub async fn prepare_env_from_config_str(config_str: &str) -> Arc<PeerManager> {
let config = TomlConfigLoader::new_from_str(config_str).expect("invalid test config");
let ctx = Arc::new(GlobalCtx::new(config));
let (s, _r) = create_packet_recv_chan();
let peer_mgr = Arc::new(PeerManager::new(RouteAlgoType::Ospf, ctx, s));
peer_mgr.run().await.unwrap();
replace_stun_info_collector(peer_mgr.clone(), NatType::PortRestricted);
peer_mgr
}
pub fn zone_data_a(origin: &str, record: &str) -> ZoneData {
zone_data_a_with_forwarders(origin, record, vec![])
}
pub fn zone_data_a_with_forwarders(origin: &str, record: &str, forwarders: Vec<&str>) -> ZoneData {
ZoneData::new(
&origin.parse().unwrap(),
60,
[format!("@ IN A {record}")],
forwarders.into_iter().map(|f| Url::from_str(f).unwrap()),
hashset! {},
)
}
pub fn dns_snapshot_with(
zones: Vec<ZoneData>,
addresses: Vec<&str>,
listeners: Vec<&str>,
) -> DnsSnapshot {
DnsSnapshot {
zones,
addresses: addresses
.into_iter()
.map(|a| Url::from_str(a).expect("invalid address"))
.collect(),
listeners: listeners
.into_iter()
.map(|l| Url::from_str(l).expect("invalid listener"))
.collect(),
}
}
pub fn heartbeat_with_snapshot(id: Uuid, snapshot: DnsSnapshot) -> HeartbeatRequest {
let mut hb = HeartbeatRequest {
id: Some(id.into()),
..Default::default()
};
hb.update(snapshot);
hb
}
fn find_free_udp_port() -> u16 {
std::net::UdpSocket::bind("127.0.0.1:0")
.expect("failed to bind temp udp socket")
.local_addr()
.expect("failed to get local addr")
.port()
}
pub async fn check_dns_record(fake_ip: &Ipv4Addr, domain: &str, expected_ip: &str) {
check_dns_record_at(SocketAddr::new((*fake_ip).into(), 53), domain, expected_ip).await;
}
pub async fn check_dns_record_at(server_addr: SocketAddr, domain: &str, expected_ip: &str) {
let expected = expected_ip.parse::<Ipv4Addr>().unwrap();
let name = Name::from_str(domain).unwrap();
let deadline = Instant::now() + Duration::from_secs(30);
loop {
let stream = UdpClientStream::builder(server_addr, TokioRuntimeProvider::default()).build();
let (mut client, background) = Client::<TokioRuntimeProvider>::from_sender(stream);
let background_task = tokio::spawn(background);
let query_result = tokio::time::timeout(
Duration::from_secs(2),
client.query(name.clone(), DNSClass::IN, RecordType::A),
)
.await;
background_task.abort();
let _ = background_task.await;
let attempt_err = match query_result {
Ok(Ok(response)) => {
if response.answers.len() == 1
&& let Some(resp) = response.answers.first()
&& matches!(resp.clone().data, RData::A(a) if a.0 == expected)
{
return;
}
format!("unexpected response: {:?}", response.answers)
}
Ok(Err(e)) => {
format!("DNS query failed for domain '{domain}': {e}")
}
Err(_) => {
format!("DNS query timed out for domain '{domain}'")
}
};
if Instant::now() >= deadline {
panic!(
"DNS query failed unexpectedly for domain '{domain}' after retries: {attempt_err}"
);
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
}
pub async fn check_dns_record_missing(fake_ip: &Ipv4Addr, domain: &str) {
check_dns_record_missing_at(SocketAddr::new((*fake_ip).into(), 53), domain).await;
}
pub async fn check_dns_record_missing_at(server_addr: SocketAddr, domain: &str) {
let deadline = Instant::now() + Duration::from_secs(30);
loop {
let stream = UdpClientStream::builder(server_addr, TokioRuntimeProvider::default()).build();
let (mut client, background) = Client::<TokioRuntimeProvider>::from_sender(stream);
let background_task = tokio::spawn(background);
let query_result = tokio::time::timeout(
Duration::from_secs(2),
client.query(Name::from_str(domain).unwrap(), DNSClass::IN, RecordType::A),
)
.await;
background_task.abort();
let _ = background_task.await;
let attempt_err = match query_result {
Ok(Ok(response)) => {
if response.answers.is_empty() {
return;
}
format!("unexpected non-empty response: {:?}", response.answers)
}
Ok(Err(e)) => {
format!("DNS query for missing record failed for domain '{domain}': {e}")
}
Err(_) => {
format!("DNS query for missing record timed out for domain '{domain}'")
}
};
if Instant::now() >= deadline {
panic!(
"missing-record query failed unexpectedly for domain '{domain}' after retries: {attempt_err}"
);
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
}
pub fn new_request(name: &str, rtype: RecordType) -> anyhow::Result<Request> {
let mut msg = Message::new(0, MessageType::Query, OpCode::Query);
msg.metadata.recursion_desired = true;
msg.add_query(Query::query(Name::from_ascii(name)?, rtype));
let mut request = Vec::new();
let mut encoder = BinEncoder::new(&mut request);
msg.emit(&mut encoder)?;
Ok(Request::from_bytes(
request,
SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0).into(),
Protocol::Udp,
)?)
}
async fn wait_route_disappear(peer_mgr: Arc<PeerManager>, target_peer_id: u32) {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let has_route = peer_mgr
.list_routes()
.await
.iter()
.any(|r| r.peer_id == target_peer_id);
if !has_route {
return;
}
assert!(
Instant::now() < deadline,
"route to peer {} did not disappear in time",
target_peer_id
);
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
async fn disconnect_all_peer_conns(a: Arc<PeerManager>, b: Arc<PeerManager>) {
if let Some(conns) = a.get_peer_map().list_peer_conns(b.my_peer_id()).await {
for conn in conns {
let conn_id = conn.conn_id.parse().expect("invalid conn id");
let _ = a.close_peer_conn(b.my_peer_id(), &conn_id).await;
}
}
if let Some(conns) = b.get_peer_map().list_peer_conns(a.my_peer_id()).await {
for conn in conns {
let conn_id = conn.conn_id.parse().expect("invalid conn id");
let _ = b.close_peer_conn(a.my_peer_id(), &conn_id).await;
}
}
}
async fn check_dns_unavailable_at(server_addr: SocketAddr, domain: &str) {
let deadline = Instant::now() + Duration::from_secs(15);
let name = rr::Name::from_str(domain).unwrap();
loop {
let stream = UdpClientStream::builder(server_addr, TokioRuntimeProvider::default()).build();
let (mut client, background) = Client::<TokioRuntimeProvider>::from_sender(stream);
let background_task = tokio::spawn(background);
let query_result = tokio::time::timeout(
Duration::from_secs(1),
client.query(name.clone(), DNSClass::IN, RecordType::A),
)
.await;
background_task.abort();
let _ = background_task.await;
match query_result {
Ok(Ok(response)) if !response.answers.is_empty() => {
if Instant::now() >= deadline {
panic!(
"DNS endpoint {server_addr} still answered for '{domain}': {:?}",
response.answers
);
}
}
_ => return,
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
}
async fn wait_peer_zone_visibility(
peer_mgr: Arc<PeerManager>,
target_peer_id: u32,
zone_origin_substr: &str,
expected_visible: bool,
) {
let dns = DnsPeerMgr::new(peer_mgr.clone(), peer_mgr.get_global_ctx());
let deadline = Instant::now() + Duration::from_secs(20);
loop {
let _ = dns
.refresh(target_peer_id, Default::default(), Default::default())
.await;
let snapshot = dns.snapshot();
let visible = snapshot.zones.iter().any(|z| {
z.content
.contains(&format!("$ORIGIN {}", zone_origin_substr))
});
if visible == expected_visible {
return;
}
assert!(
Instant::now() < deadline,
"zone visibility mismatch for '{}': expected {}, got {}",
zone_origin_substr,
expected_visible,
visible,
);
tokio::time::sleep(Duration::from_millis(200)).await;
}
}
fn cfg_with_listener(name: &str, ipv4: &str, domain: &str, listener_port: u16) -> String {
format!(
r#"
instance_name = "dns-int-{name}"
ipv4 = "{ipv4}"
[dns]
name = "{name}"
domain = "{domain}"
listeners = ["udp://127.0.0.1:{listener_port}"]
"#
)
}
#[tokio::test]
#[serial_test::serial(dns_integration_rpc)]
async fn config_string_single_node_resolves_self_and_custom_zone() {
let listener_port = find_free_udp_port();
let config = format!(
r#"
{}
[[dns.zone]]
origin = "services.alpha.et-test"
records = ["api IN A 10.77.0.7"]
[dns.zone.export]
whitelist = ["*"]
"#,
cfg_with_listener("alpha", "10.144.144.11/24", "et-test", listener_port)
);
let peer_mgr = prepare_env_from_config_str(&config).await;
let mut dns_node = start_dns_node_without_nic(peer_mgr);
let server_addr = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), listener_port);
check_dns_record_at(server_addr, "alpha.et-test.", "10.144.144.11").await;
check_dns_record_at(server_addr, "api.services.alpha.et-test.", "10.77.0.7").await;
dns_node.stop().await.unwrap();
}
#[tokio::test]
#[serial_test::serial(dns_integration_rpc)]
async fn config_string_two_nodes_sync_self_zone_and_exported_zone() {
let listener_a = find_free_udp_port();
let listener_b = find_free_udp_port();
let config_a = format!(
r#"
{}
[[dns.zone]]
origin = "shared.mesh-test"
records = ["app IN A 10.66.0.8"]
[dns.zone.export]
whitelist = ["*"]
"#,
cfg_with_listener("node-a", "10.144.144.21/24", "mesh-test", listener_a)
);
let config_b = cfg_with_listener("node-b", "10.144.144.22/24", "mesh-test", listener_b);
let peer_a = prepare_env_from_config_str(&config_a).await;
let peer_b = prepare_env_from_config_str(&config_b).await;
connect_peer_manager(peer_a.clone(), peer_b.clone()).await;
wait_route_appear(peer_a.clone(), peer_b.clone())
.await
.expect("route should appear");
let mut node_a = start_dns_node_without_nic(peer_a);
let mut node_b = start_dns_node_without_nic(peer_b);
let addr_a = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), listener_a);
let addr_b = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), listener_b);
check_dns_record_at(addr_a, "node-b.mesh-test.", "10.144.144.22").await;
check_dns_record_at(addr_b, "node-b.mesh-test.", "10.144.144.22").await;
check_dns_record_at(addr_a, "app.shared.mesh-test.", "10.66.0.8").await;
check_dns_record_at(addr_b, "app.shared.mesh-test.", "10.66.0.8").await;
node_a.stop().await.unwrap();
node_b.stop().await.unwrap();
}
#[tokio::test]
#[serial_test::serial(dns_integration_rpc)]
async fn config_string_zone_without_export_section_is_not_synced() {
let listener_a = find_free_udp_port();
let listener_b = find_free_udp_port();
let config_a = format!(
r#"
{}
[[dns.zone]]
origin = "private.mesh-test"
records = ["secret IN A 10.99.0.9"]
"#,
cfg_with_listener("local-a", "10.144.144.31/24", "mesh-test", listener_a)
);
let config_b = cfg_with_listener("local-b", "10.144.144.32/24", "mesh-test", listener_b);
let peer_a = prepare_env_from_config_str(&config_a).await;
let peer_b = prepare_env_from_config_str(&config_b).await;
connect_peer_manager(peer_a.clone(), peer_b.clone()).await;
wait_route_appear(peer_a.clone(), peer_b.clone())
.await
.expect("route should appear");
// Export behavior is determined by whether `[dns.zone.export]` exists.
// Verify from peer-sync view to avoid host-wide DNS-server election side effects.
let dns_a = DnsPeerMgr::new(peer_a.clone(), peer_a.get_global_ctx());
dns_a.register();
let dns_b = DnsPeerMgr::new(peer_b.clone(), peer_b.get_global_ctx());
dns_b.register();
dns_b
.refresh(peer_a.my_peer_id(), Default::default(), Default::default())
.await
.unwrap();
let snapshot = dns_b.snapshot();
assert!(
!snapshot
.zones
.iter()
.any(|z| z.content.contains("$ORIGIN private.mesh-test")),
"zone without [dns.zone.export] should not be exported to peer snapshot"
);
}
#[tokio::test]
#[serial_test::serial(dns_integration_rpc)]
async fn config_string_export_section_disabled_is_not_synced() {
let listener_a = find_free_udp_port();
let listener_b = find_free_udp_port();
let config_a = format!(
r#"
{}
[[dns.zone]]
origin = "disabled.mesh-test"
records = ["secret IN A 10.99.1.9"]
[dns.zone.export]
disabled = true
"#,
cfg_with_listener("local-da", "10.144.144.41/24", "mesh-test", listener_a)
);
let config_b = cfg_with_listener("local-db", "10.144.144.42/24", "mesh-test", listener_b);
let peer_a = prepare_env_from_config_str(&config_a).await;
let peer_b = prepare_env_from_config_str(&config_b).await;
connect_peer_manager(peer_a.clone(), peer_b.clone()).await;
wait_route_appear(peer_a.clone(), peer_b.clone())
.await
.expect("route should appear");
let dns_a = DnsPeerMgr::new(peer_a.clone(), peer_a.get_global_ctx());
dns_a.register();
let dns_b = DnsPeerMgr::new(peer_b.clone(), peer_b.get_global_ctx());
dns_b.register();
dns_b
.refresh(peer_a.my_peer_id(), Default::default(), Default::default())
.await
.unwrap();
let snapshot = dns_b.snapshot();
assert!(
!snapshot
.zones
.iter()
.any(|z| z.content.contains("$ORIGIN disabled.mesh-test")),
"zone with [dns.zone.export] disabled=true should not be exported"
);
}
#[tokio::test]
#[serial_test::serial(dns_integration_rpc)]
async fn config_patch_updates_zone_record_visible_on_query() {
let listener = find_free_udp_port();
let config = format!(
r#"
{}
[[dns.zone]]
origin = "patch.mesh-test"
records = ["api IN A 10.80.0.1"]
[dns.zone.export]
"#,
cfg_with_listener("patch-node", "10.144.149.11/24", "mesh-test", listener)
);
let peer = prepare_env_from_config_str(&config).await;
let mut dns_node = start_dns_node_without_nic(peer.clone());
let server_addr = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), listener);
check_dns_record_at(server_addr, "api.patch.mesh-test.", "10.80.0.1").await;
let mut dns = peer.get_global_ctx().config.get_dns().into_raw();
let mut zones = dns.zones.unwrap();
let zone_idx = zones
.iter()
.position(|z| z.origin.to_string().contains("patch.mesh-test"))
.expect("patch zone should exist");
let mut zone = zones[zone_idx].clone().into_raw();
zone.records = Some(vec!["api IN A 10.80.0.2".to_string()]);
zones[zone_idx] = zone.try_into().expect("patch zone update should be valid");
dns.zones = Some(zones);
peer.get_global_ctx().config.set_dns(dns.into());
peer.get_global_ctx()
.issue_event(crate::common::global_ctx::GlobalCtxEvent::ConfigPatched(
crate::proto::api::config::InstanceConfigPatch::default(),
));
check_dns_record_at(server_addr, "api.patch.mesh-test.", "10.80.0.2").await;
dns_node.stop().await.unwrap();
}
#[tokio::test]
#[serial_test::serial(dns_integration_rpc)]
async fn config_patch_reloads_listener_binding() {
let listener_old = find_free_udp_port();
let listener_new = find_free_udp_port();
let config = cfg_with_listener(
"listener-patch",
"10.144.150.11/24",
"mesh-test",
listener_old,
);
let peer = prepare_env_from_config_str(&config).await;
let mut dns_node = start_dns_node_without_nic(peer.clone());
let old_addr = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), listener_old);
let new_addr = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), listener_new);
check_dns_record_at(old_addr, "listener-patch.mesh-test.", "10.144.150.11").await;
let mut dns = peer.get_global_ctx().config.get_dns().into_raw();
dns.listeners = Some(
vec![
format!("udp://127.0.0.1:{listener_new}")
.parse()
.expect("invalid listener"),
]
.into(),
);
peer.get_global_ctx().config.set_dns(dns.into());
peer.get_global_ctx()
.issue_event(crate::common::global_ctx::GlobalCtxEvent::ConfigPatched(
crate::proto::api::config::InstanceConfigPatch::default(),
));
check_dns_record_at(new_addr, "listener-patch.mesh-test.", "10.144.150.11").await;
check_dns_unavailable_at(old_addr, "listener-patch.mesh-test.").await;
dns_node.stop().await.unwrap();
}
#[tokio::test]
#[serial_test::serial(dns_integration_rpc)]
async fn config_string_three_nodes_partition_and_recover_dns_propagation() {
let listener_a = find_free_udp_port();
let listener_b = find_free_udp_port();
let listener_c = find_free_udp_port();
let config_a = cfg_with_listener("node-a7", "10.144.151.11/24", "mesh7-test", listener_a);
let config_b = cfg_with_listener("node-b7", "10.144.151.12/24", "mesh7-test", listener_b);
let config_c = format!(
r#"
{}
[[dns.zone]]
origin = "shared-c7.mesh7-test"
records = ["svc IN A 10.77.7.7"]
[dns.zone.export]
"#,
cfg_with_listener("node-c7", "10.144.151.13/24", "mesh7-test", listener_c)
);
let peer_a = prepare_env_from_config_str(&config_a).await;
let peer_b = prepare_env_from_config_str(&config_b).await;
let peer_c = prepare_env_from_config_str(&config_c).await;
connect_peer_manager(peer_a.clone(), peer_b.clone()).await;
connect_peer_manager(peer_b.clone(), peer_c.clone()).await;
wait_route_appear(peer_a.clone(), peer_c.clone())
.await
.expect("route a-c should appear via b");
let mut node_a = start_dns_node_without_nic(peer_a.clone());
let mut node_b = start_dns_node_without_nic(peer_b.clone());
let mut node_c = start_dns_node_without_nic(peer_c.clone());
let addr_a = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), listener_a);
check_dns_record_at(addr_a, "node-c7.mesh7-test.", "10.144.151.13").await;
check_dns_record_at(addr_a, "svc.shared-c7.mesh7-test.", "10.77.7.7").await;
wait_peer_zone_visibility(
peer_a.clone(),
peer_c.my_peer_id(),
"node-c7.mesh7-test",
true,
)
.await;
wait_peer_zone_visibility(
peer_a.clone(),
peer_c.my_peer_id(),
"shared-c7.mesh7-test",
true,
)
.await;
disconnect_all_peer_conns(peer_b.clone(), peer_c.clone()).await;
wait_route_disappear(peer_a.clone(), peer_c.my_peer_id()).await;
// Validate via peer-sync snapshot to avoid process-wide DNS-server election side effects.
wait_peer_zone_visibility(
peer_a.clone(),
peer_c.my_peer_id(),
"node-c7.mesh7-test",
false,
)
.await;
wait_peer_zone_visibility(
peer_a.clone(),
peer_c.my_peer_id(),
"shared-c7.mesh7-test",
false,
)
.await;
connect_peer_manager(peer_b.clone(), peer_c.clone()).await;
wait_route_appear(peer_a.clone(), peer_c.clone())
.await
.expect("route a-c should recover via b");
wait_peer_zone_visibility(
peer_a.clone(),
peer_c.my_peer_id(),
"node-c7.mesh7-test",
true,
)
.await;
wait_peer_zone_visibility(
peer_a.clone(),
peer_c.my_peer_id(),
"shared-c7.mesh7-test",
true,
)
.await;
check_dns_record_at(addr_a, "node-c7.mesh7-test.", "10.144.151.13").await;
check_dns_record_at(addr_a, "svc.shared-c7.mesh7-test.", "10.77.7.7").await;
node_a.stop().await.unwrap();
node_b.stop().await.unwrap();
node_c.stop().await.unwrap();
}
#[tokio::test]
#[serial_test::serial(dns_integration_rpc)]
async fn config_string_three_nodes_chain_sync_self_and_exported_zone() {
let listener_a = find_free_udp_port();
let listener_b = find_free_udp_port();
let listener_c = find_free_udp_port();
let config_a = cfg_with_listener("node-a3", "10.144.145.11/24", "mesh3-test", listener_a);
let config_b = cfg_with_listener("node-b3", "10.144.145.12/24", "mesh3-test", listener_b);
let config_c = format!(
r#"
{}
[[dns.zone]]
origin = "shared-c.mesh3-test"
records = ["api IN A 10.66.1.8"]
[dns.zone.export]
"#,
cfg_with_listener("node-c3", "10.144.145.13/24", "mesh3-test", listener_c)
);
let peer_a = prepare_env_from_config_str(&config_a).await;
let peer_b = prepare_env_from_config_str(&config_b).await;
let peer_c = prepare_env_from_config_str(&config_c).await;
connect_peer_manager(peer_a.clone(), peer_b.clone()).await;
connect_peer_manager(peer_b.clone(), peer_c.clone()).await;
wait_route_appear(peer_a.clone(), peer_b.clone())
.await
.expect("route a-b should appear");
wait_route_appear(peer_b.clone(), peer_c.clone())
.await
.expect("route b-c should appear");
wait_route_appear(peer_a.clone(), peer_c.clone())
.await
.expect("route a-c should appear via b");
let mut node_a = start_dns_node_without_nic(peer_a);
let mut node_b = start_dns_node_without_nic(peer_b);
let mut node_c = start_dns_node_without_nic(peer_c);
let addr_a = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), listener_a);
let addr_b = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), listener_b);
let addr_c = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), listener_c);
check_dns_record_at(addr_a, "node-c3.mesh3-test.", "10.144.145.13").await;
check_dns_record_at(addr_b, "node-c3.mesh3-test.", "10.144.145.13").await;
check_dns_record_at(addr_c, "node-a3.mesh3-test.", "10.144.145.11").await;
check_dns_record_at(addr_a, "api.shared-c.mesh3-test.", "10.66.1.8").await;
check_dns_record_at(addr_c, "api.shared-c.mesh3-test.", "10.66.1.8").await;
node_a.stop().await.unwrap();
node_b.stop().await.unwrap();
node_c.stop().await.unwrap();
}
#[tokio::test]
#[serial_test::serial(dns_integration_rpc)]
async fn config_string_three_nodes_late_join_propagates_dns() {
let listener_a = find_free_udp_port();
let listener_b = find_free_udp_port();
let listener_c = find_free_udp_port();
let config_a = cfg_with_listener("node-a4", "10.144.146.11/24", "mesh4-test", listener_a);
let config_b = cfg_with_listener("node-b4", "10.144.146.12/24", "mesh4-test", listener_b);
let config_c = format!(
r#"
{}
[[dns.zone]]
origin = "joined.mesh4-test"
records = ["svc IN A 10.66.2.8"]
[dns.zone.export]
"#,
cfg_with_listener("node-c4", "10.144.146.13/24", "mesh4-test", listener_c)
);
let peer_a = prepare_env_from_config_str(&config_a).await;
let peer_b = prepare_env_from_config_str(&config_b).await;
let peer_c = prepare_env_from_config_str(&config_c).await;
connect_peer_manager(peer_a.clone(), peer_b.clone()).await;
wait_route_appear(peer_a.clone(), peer_b.clone())
.await
.expect("route a-b should appear");
let mut node_a = start_dns_node_without_nic(peer_a.clone());
let mut node_b = start_dns_node_without_nic(peer_b.clone());
let addr_a = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), listener_a);
check_dns_record_missing_at(addr_a, "node-c4.mesh4-test.").await;
connect_peer_manager(peer_b.clone(), peer_c.clone()).await;
wait_route_appear(peer_b.clone(), peer_c.clone())
.await
.expect("route b-c should appear");
wait_route_appear(peer_a.clone(), peer_c.clone())
.await
.expect("route a-c should appear after c joins");
let mut node_c = start_dns_node_without_nic(peer_c);
check_dns_record_at(addr_a, "node-c4.mesh4-test.", "10.144.146.13").await;
check_dns_record_at(addr_a, "svc.joined.mesh4-test.", "10.66.2.8").await;
node_a.stop().await.unwrap();
node_b.stop().await.unwrap();
node_c.stop().await.unwrap();
}
#[tokio::test]
#[serial_test::serial(dns_integration_rpc)]
async fn config_string_three_nodes_zone_without_export_not_synced_across_hop() {
let listener_a = find_free_udp_port();
let listener_b = find_free_udp_port();
let listener_c = find_free_udp_port();
let config_a = cfg_with_listener("node-a5", "10.144.147.11/24", "mesh5-test", listener_a);
let config_b = cfg_with_listener("node-b5", "10.144.147.12/24", "mesh5-test", listener_b);
let config_c = format!(
r#"
{}
[[dns.zone]]
origin = "private-c.mesh5-test"
records = ["secret IN A 10.66.3.8"]
"#,
cfg_with_listener("node-c5", "10.144.147.13/24", "mesh5-test", listener_c)
);
let peer_a = prepare_env_from_config_str(&config_a).await;
let peer_b = prepare_env_from_config_str(&config_b).await;
let peer_c = prepare_env_from_config_str(&config_c).await;
connect_peer_manager(peer_a.clone(), peer_b.clone()).await;
connect_peer_manager(peer_b.clone(), peer_c.clone()).await;
wait_route_appear(peer_a.clone(), peer_c.clone())
.await
.expect("route a-c should appear via b");
let dns_c = DnsPeerMgr::new(peer_c.clone(), peer_c.get_global_ctx());
dns_c.register();
let dns_a = DnsPeerMgr::new(peer_a.clone(), peer_a.get_global_ctx());
dns_a.register();
dns_a
.refresh(peer_c.my_peer_id(), Default::default(), Default::default())
.await
.unwrap();
let snapshot = dns_a.snapshot();
assert!(
!snapshot
.zones
.iter()
.any(|z| z.content.contains("$ORIGIN private-c.mesh5-test")),
"zone without [dns.zone.export] should not sync over multi-hop"
);
}
#[tokio::test]
#[serial_test::serial(dns_integration_rpc)]
async fn config_string_two_nodes_peer_dns_offline_then_rejoin() {
let listener_a = find_free_udp_port();
let listener_b = find_free_udp_port();
let config_a = cfg_with_listener("node-a6", "10.144.148.11/24", "mesh6-test", listener_a);
let config_b = cfg_with_listener("node-b6", "10.144.148.12/24", "mesh6-test", listener_b);
let peer_a = prepare_env_from_config_str(&config_a).await;
let peer_b = prepare_env_from_config_str(&config_b).await;
connect_peer_manager(peer_a.clone(), peer_b.clone()).await;
wait_route_appear(peer_a.clone(), peer_b.clone())
.await
.expect("route should appear");
let dns_b = DnsPeerMgr::new(peer_b.clone(), peer_b.get_global_ctx());
dns_b.register();
let dns_a_online = DnsPeerMgr::new(peer_a.clone(), peer_a.get_global_ctx());
dns_a_online.register();
dns_a_online
.refresh(peer_b.my_peer_id(), Default::default(), Default::default())
.await
.unwrap();
assert!(
dns_a_online
.snapshot()
.zones
.iter()
.any(|z| z.content.contains("$ORIGIN node-b6.mesh6-test")),
"peer B self zone should be visible after initial refresh"
);
// Simulate peer offline by closing all direct connections and waiting route withdrawal.
if let Some(conns) = peer_a
.get_peer_map()
.list_peer_conns(peer_b.my_peer_id())
.await
{
for conn in conns {
let conn_id = conn.conn_id.parse().expect("invalid conn id");
let _ = peer_a.close_peer_conn(peer_b.my_peer_id(), &conn_id).await;
}
}
if let Some(conns) = peer_b
.get_peer_map()
.list_peer_conns(peer_a.my_peer_id())
.await
{
for conn in conns {
let conn_id = conn.conn_id.parse().expect("invalid conn id");
let _ = peer_b.close_peer_conn(peer_a.my_peer_id(), &conn_id).await;
}
}
wait_route_disappear(peer_a.clone(), peer_b.my_peer_id()).await;
wait_route_disappear(peer_b.clone(), peer_a.my_peer_id()).await;
// Cached remote zones should be purged after peer cache idle timeout.
tokio::time::sleep(Duration::from_secs(4)).await;
let dns_a_offline = DnsPeerMgr::new(peer_a.clone(), peer_a.get_global_ctx());
assert!(
!dns_a_offline
.snapshot()
.zones
.iter()
.any(|z| z.content.contains("$ORIGIN node-b6.mesh6-test")),
"peer B self zone should disappear after route withdrawal and cache expiry"
);
connect_peer_manager(peer_a.clone(), peer_b.clone()).await;
wait_route_appear(peer_a.clone(), peer_b.clone())
.await
.expect("route should re-appear");
let dns_a_rejoin = DnsPeerMgr::new(peer_a.clone(), peer_a.get_global_ctx());
dns_a_rejoin
.refresh(peer_b.my_peer_id(), Default::default(), Default::default())
.await
.unwrap();
assert!(
dns_a_rejoin
.snapshot()
.zones
.iter()
.any(|z| z.content.contains("$ORIGIN node-b6.mesh6-test")),
"peer B self zone should be restored after DNS RPC rejoins"
);
}
+183
View File
@@ -0,0 +1,183 @@
use crate::proto;
use crate::proto::utils::{RepeatedDeserialize, RepeatedMessageModel, RepeatedSerialize};
use anyhow::{Error, anyhow};
use hickory_net::xfer::Protocol;
use hickory_resolver::config::{ConnectionConfig, NameServerConfig, ProtocolConfig};
use serde::de::IntoDeserializer;
use serde::{Deserialize, Deserializer, de};
use serde_with::{DeserializeFromStr, SerializeDisplay};
use std::fmt::{Display, Formatter};
use std::net::{IpAddr, Ipv6Addr, SocketAddr};
use std::str::FromStr;
use url::Url;
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, SerializeDisplay, DeserializeFromStr)]
pub struct NameServerAddr {
pub protocol: Protocol,
pub addr: SocketAddr,
}
impl From<NameServerAddr> for NameServerConfig {
fn from(value: NameServerAddr) -> Self {
let mut config = match value.protocol {
Protocol::Udp => ConnectionConfig::udp(),
Protocol::Tcp => ConnectionConfig::tcp(),
_ => unimplemented!(),
};
config.port = value.addr.port();
Self::new(value.addr.ip(), true, vec![config])
}
}
impl From<(IpAddr, &ConnectionConfig)> for NameServerAddr {
fn from(value: (IpAddr, &ConnectionConfig)) -> Self {
let (ip, config) = value;
Self {
protocol: config.protocol.to_protocol(),
addr: SocketAddr::new(ip, config.port),
}
}
}
impl TryFrom<&Url> for NameServerAddr {
type Error = Error;
fn try_from(url: &Url) -> Result<Self, Self::Error> {
let protocol = match Protocol::deserialize(url.scheme().into_deserializer())
.map_err(|e: de::value::Error| anyhow!("invalid protocol '{}': {}", url.scheme(), e))?
{
Protocol::Udp => ProtocolConfig::Udp,
Protocol::Tcp => ProtocolConfig::Tcp,
p => return Err(anyhow!("unsupported protocol: {}", p)),
};
let host = url.host_str().ok_or(anyhow!("host not found"))?;
let port = url.port().unwrap_or(protocol.default_port());
let addr = if let Ok(addr) = IpAddr::from_str(host) {
SocketAddr::new(addr, port)
} else {
return Err(anyhow!("invalid address: {}", host));
};
Ok(Self {
protocol: protocol.to_protocol(),
addr,
})
}
}
impl TryFrom<&proto::common::Url> for NameServerAddr {
type Error = Error;
fn try_from(value: &proto::common::Url) -> Result<Self, Self::Error> {
(&Url::try_from(value)?).try_into()
}
}
impl From<&NameServerAddr> for Url {
fn from(value: &NameServerAddr) -> Self {
Url::parse(&format!("{}://{}", value.protocol, value.addr)).unwrap()
}
}
impl From<&NameServerAddr> for proto::common::Url {
fn from(value: &NameServerAddr) -> Self {
Url::from(value).into()
}
}
impl From<NameServerAddr> for Url {
fn from(value: NameServerAddr) -> Self {
(&value).into()
}
}
impl From<NameServerAddr> for proto::common::Url {
fn from(value: NameServerAddr) -> Self {
(&value).into()
}
}
impl FromStr for NameServerAddr {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
(&Url::parse(s)?).try_into()
}
}
impl Display for NameServerAddr {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_str(Url::from(*self).as_str())
}
}
pub type NameServerAddrGroup = RepeatedMessageModel<NameServerAddr>;
impl From<&NameServerConfig> for NameServerAddrGroup {
fn from(value: &NameServerConfig) -> Self {
value
.connections
.iter()
.map(|c| (value.ip, c).into())
.collect()
}
}
impl From<SocketAddr> for NameServerAddrGroup {
fn from(value: SocketAddr) -> Self {
vec![
NameServerAddr {
protocol: Protocol::Udp,
addr: value,
},
NameServerAddr {
protocol: Protocol::Tcp,
addr: value,
},
]
.into()
}
}
impl From<IpAddr> for NameServerAddrGroup {
fn from(value: IpAddr) -> Self {
SocketAddr::new(value, 53).into()
}
}
impl From<u16> for NameServerAddrGroup {
fn from(value: u16) -> Self {
SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), value).into()
}
}
impl RepeatedSerialize for NameServerAddr {}
impl<'de> RepeatedDeserialize<'de> for NameServerAddr {
fn deserialize<D>(deserializer: D) -> Result<NameServerAddrGroup, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum Candidate {
NameServerAddr(NameServerAddr),
U16(u16),
IpAddr(IpAddr),
SocketAddr(SocketAddr),
}
let items = Vec::<Candidate>::deserialize(deserializer)?;
let items = items
.into_iter()
.flat_map(|item| -> NameServerAddrGroup {
match item {
Candidate::NameServerAddr(addr) => vec![addr].into(),
Candidate::U16(port) => port.into(),
Candidate::IpAddr(ip) => ip.into(),
Candidate::SocketAddr(addr) => addr.into(),
}
})
.collect();
Ok(items)
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod addr;
pub mod response;
pub mod zone_handler;
+59
View File
@@ -0,0 +1,59 @@
use hickory_net::NetError;
use hickory_proto::rr::Record;
use hickory_proto::serialize::binary::BinEncoder;
use hickory_server::server::{ResponseHandler, ResponseInfo};
use hickory_server::zone_handler::MessageResponse;
use parking_lot::Mutex;
use std::sync::Arc;
// ResponseWrapper for serializing DNS responses into a byte buffer.
// Used by the address hijacking NIC packet filter to produce DNS replies in-place.
#[derive(Debug, Clone)]
pub struct ResponseHandle {
inner: Arc<Mutex<Vec<u8>>>,
}
impl ResponseHandle {
pub fn new(capacity: usize) -> Self {
Self {
inner: Arc::new(Mutex::new(Vec::with_capacity(capacity))),
}
}
pub fn into_inner(self) -> Option<Vec<u8>> {
Arc::into_inner(self.inner).map(Mutex::into_inner)
}
}
pub trait RecordIter<'r>: Iterator<Item = &'r Record> + Send + 'r {}
impl<'r, T> RecordIter<'r> for T where T: Iterator<Item = &'r Record> + Send + 'r {}
#[async_trait::async_trait]
impl ResponseHandler for ResponseHandle {
async fn send_response<'r>(
&mut self,
response: MessageResponse<
'_,
'r,
impl RecordIter<'r>,
impl RecordIter<'r>,
impl RecordIter<'r>,
impl RecordIter<'r>,
>,
) -> Result<ResponseInfo, NetError> {
let max_size = if let Some(edns) = response.edns() {
edns.max_payload()
} else {
hickory_net::udp::MAX_RECEIVE_BUFFER_SIZE as u16
};
let mut inner = self.inner.lock();
inner.clear();
let mut encoder = BinEncoder::new(inner.as_mut());
encoder.set_max_size(max_size);
response
.destructive_emit(&mut encoder)
.map_err(NetError::Proto)
}
}
+85
View File
@@ -0,0 +1,85 @@
use crate::dns::config::zone::Fallthrough;
use delegate::delegate;
use derive_more::{Constructor, Deref, DerefMut};
use hickory_proto::op::ResponseCode;
use hickory_proto::rr::{LowerName, RecordType, TSigResponseContext};
use hickory_server::server::{Request, RequestInfo};
use hickory_server::zone_handler::{
AuthLookup, AxfrPolicy, LookupControlFlow, LookupError, LookupOptions, ZoneHandler, ZoneType,
};
use std::collections::HashSet;
use std::sync::Arc;
pub type ArcZoneHandler = Arc<dyn ZoneHandler>;
#[derive(Constructor, Deref, DerefMut)]
pub struct ChainedZoneHandler<H>
where
H: ZoneHandler,
{
#[deref]
#[deref_mut]
handler: H,
fallthrough: HashSet<Fallthrough>,
}
#[async_trait::async_trait]
impl<H> ZoneHandler for ChainedZoneHandler<H>
where
H: ZoneHandler,
{
delegate! {
to self.handler {
fn zone_type(&self) -> ZoneType;
fn axfr_policy(&self) -> AxfrPolicy;
fn origin(&self) -> &LowerName;
}
}
#[inline]
async fn update(
&self,
update: &Request,
now: u64,
) -> (Result<bool, ResponseCode>, Option<TSigResponseContext>) {
self.handler.update(update, now).await
}
#[inline]
async fn lookup(
&self,
name: &LowerName,
rtype: RecordType,
request_info: Option<&RequestInfo<'_>>,
lookup_options: LookupOptions,
) -> LookupControlFlow<AuthLookup> {
self.handler
.lookup(name, rtype, request_info, lookup_options)
.await
}
#[inline]
async fn search(
&self,
request: &Request,
lookup_options: LookupOptions,
) -> (LookupControlFlow<AuthLookup>, Option<TSigResponseContext>) {
let (result, tsig) = self.handler.search(request, lookup_options).await;
match &result {
LookupControlFlow::Continue(Err(e)) | LookupControlFlow::Break(Err(e))
if self.fallthrough.contains(&Fallthrough::Any)
|| matches!(e, LookupError::ResponseCode(c) if self.fallthrough.contains(&(*c).into())) =>
{
(LookupControlFlow::Skip, None)
}
_ => (result, tsig),
}
}
#[inline]
async fn nsec_records(
&self,
name: &LowerName,
lookup_options: LookupOptions,
) -> LookupControlFlow<AuthLookup> {
self.handler.nsec_records(name, lookup_options).await
}
}
+572
View File
@@ -0,0 +1,572 @@
use crate::dns::config::zone::Fallthrough;
use crate::dns::utils::addr::{NameServerAddr, NameServerAddrGroup};
use crate::dns::utils::zone_handler::{ArcZoneHandler, ChainedZoneHandler};
use crate::proto::dns::ZoneData;
use crate::proto::utils::RepeatedMessageModel;
use crate::utils::dns::resolver_conf;
use hickory_net::runtime::TokioRuntimeProvider;
use hickory_proto::rr::{LowerName, RecordSet, RrKey};
use hickory_proto::serialize::txt::Parser;
use hickory_server::store::forwarder::{ForwardConfig, ForwardZoneHandler};
use hickory_server::store::in_memory::InMemoryZoneHandler;
use hickory_server::zone_handler::{AxfrPolicy, ZoneType};
use indexmap::IndexMap;
use itertools::chain;
use maplit::hashset;
use std::collections::{BTreeMap, HashSet};
use std::sync::Arc;
#[derive(Debug, Clone, Default)]
pub struct Zone {
origin: LowerName,
records: BTreeMap<RrKey, RecordSet>,
pub forward: Option<ForwardConfig>,
fallthrough: HashSet<Fallthrough>,
}
impl Zone {
pub fn system() -> Self {
let (config, opts) = resolver_conf();
let forward = ForwardConfig {
name_servers: config.name_servers().to_vec(),
options: Some(opts),
};
Self {
origin: ".".parse().unwrap(),
forward: Some(forward),
fallthrough: hashset! {},
..Default::default()
}
}
}
impl Zone {
pub fn create_memory_zone_handler(&self) -> Option<ArcZoneHandler> {
(!self.records.is_empty()).then(|| {
let mut memory = InMemoryZoneHandler::<TokioRuntimeProvider>::empty(
self.origin.clone().into(),
ZoneType::External,
AxfrPolicy::default(),
);
memory.records_get_mut().extend(
self.records
.clone()
.into_iter()
.map(|(k, v)| (k, Arc::new(v))),
);
Arc::new(ChainedZoneHandler::new(memory, self.fallthrough.clone())) as _
})
}
pub fn create_forward_zone_handler(&self) -> Option<ArcZoneHandler> {
self.forward.as_ref().and_then(|forward| {
ForwardZoneHandler::builder_with_config(
forward.clone(),
TokioRuntimeProvider::default(),
)
.build()
.inspect_err(|error| tracing::error!(?error, "failed to create forward zone_handler"))
.ok()
.map(|handler| {
Arc::new(ChainedZoneHandler::new(handler, self.fallthrough.clone())) as _
})
})
}
}
impl TryFrom<&ZoneData> for Zone {
type Error = anyhow::Error;
fn try_from(value: &ZoneData) -> Result<Self, Self::Error> {
let (origin, records) = Parser::new(&value.content, None, None)
.parse()
.map_err(|e| anyhow::anyhow!("failed to parse zone data: {e}"))?;
let name_servers = value
.forwarders
.iter()
.map(NameServerAddr::try_from)
.map(|a| a.map(Into::into))
.collect::<Result<Vec<_>, _>>()?;
let forward = (!name_servers.is_empty()).then_some(ForwardConfig {
name_servers,
options: None,
});
let fallthrough = value.fallthrough.iter().copied().map(Into::into).collect();
Ok(Self {
origin: origin.into(),
records,
forward,
fallthrough,
})
}
}
impl From<Zone> for ZoneData {
fn from(value: Zone) -> Self {
let records = value
.records
.values()
.flat_map(RecordSet::records_without_rrsigs)
.map(ToString::to_string);
let forwarders = value
.forward
.into_iter()
.flat_map(|f| f.name_servers.into_iter())
.map(|ns| (&ns).into())
.flat_map(NameServerAddrGroup::into_iter)
.map(Into::into);
Self::new(&value.origin, 0, records, forwarders, value.fallthrough)
}
}
pub type ZoneGroup = RepeatedMessageModel<Zone>;
impl ZoneGroup {
pub fn into_groups(self) -> IndexMap<LowerName, ZoneGroup> {
self.into_iter().fold(IndexMap::new(), |mut map, zone| {
map.entry(zone.origin.clone()).or_default().push(zone);
map
})
}
pub fn iter_zone_handlers(&self) -> impl Iterator<Item = ArcZoneHandler> + use<'_> {
self.iter().flat_map(|zone| {
chain(
zone.create_memory_zone_handler(),
zone.create_forward_zone_handler(),
)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dns::tests::new_request;
use crate::dns::utils::response::ResponseHandle;
use crate::proto::common::Url;
use crate::proto::dns::ZoneData;
use hickory_proto::op::{Message, ResponseCode};
use hickory_proto::rr::{RData, Record, RecordType, RrsetRecords};
use hickory_server::Server;
use hickory_server::zone_handler::Catalog;
use maplit::hashset;
use std::collections::HashSet;
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
use std::str::FromStr;
use tokio::net::UdpSocket;
use tokio::task::JoinHandle;
impl Zone {
// Test-only record iterator for precise assertions.
pub fn iter_records(&self) -> impl Iterator<Item = &Record> {
self.records
.values()
.filter(|set| !set.is_empty())
.flat_map(|set| {
let RrsetRecords::RecordsOnly(records) = set.records_without_rrsigs() else {
unreachable!()
};
records
})
}
}
fn zone_data_with_fallthrough(
origin: &str,
records: Vec<&str>,
forwarders: Vec<&str>,
fallthrough: HashSet<Fallthrough>,
) -> ZoneData {
ZoneData::new(
&origin.parse().unwrap(),
60,
records,
forwarders
.into_iter()
.map(|url| Url::from_str(url).unwrap()),
fallthrough,
)
}
fn zone_data(origin: &str, records: Vec<&str>, forwarders: Vec<&str>) -> ZoneData {
zone_data_with_fallthrough(origin, records, forwarders, hashset! {Fallthrough::Any})
}
fn build_catalog(zones: ZoneGroup) -> Catalog {
zones
.into_groups()
.into_iter()
.fold(Catalog::new(), |mut catalog, (origin, group)| {
catalog.upsert(origin, group.iter_zone_handlers().collect());
catalog
})
}
async fn lookup_message(
catalog: &Catalog,
name: &str,
record_type: RecordType,
) -> anyhow::Result<(ResponseCode, Option<Message>)> {
let request = new_request(name, record_type)?;
let response = ResponseHandle::new(1024);
let info = catalog.lookup(&request, None, 0, response.clone()).await;
let message = response
.into_inner()
.map(|raw| Message::from_vec(&raw))
.transpose()?;
Ok((info.response_code, message))
}
fn has_a_answer(message: &Message, expected: Ipv4Addr) -> bool {
message
.answers
.iter()
.any(|record| matches!(record.data, RData::A(addr) if *addr == expected))
}
fn has_aaaa_answer(message: &Message, expected: Ipv6Addr) -> bool {
message
.answers
.iter()
.any(|record| matches!(record.data, RData::AAAA(addr) if *addr == expected))
}
async fn start_upstream_server() -> anyhow::Result<(SocketAddr, JoinHandle<()>)> {
let upstream = Zone::try_from(&zone_data(
"upstream.test",
vec!["from-forward 60 IN A 203.0.113.9"],
vec![],
))?;
let mut catalog = Catalog::new();
catalog.upsert(
upstream.origin.clone(),
vec![upstream.create_memory_zone_handler().unwrap()],
);
let socket = UdpSocket::bind("127.0.0.1:0").await?;
let addr = socket.local_addr()?;
let mut server = Server::new(catalog);
server.register_socket(socket);
let handle = tokio::spawn(async move {
let _ = server.block_until_done().await;
});
Ok((addr, handle))
}
#[test]
fn zone_try_from_rejects_invalid_record() {
let data = zone_data("invalid-record.test", vec!["this is not a record"], vec![]);
let err = Zone::try_from(&data).expect_err("invalid record should fail");
assert!(err.to_string().contains("failed to parse zone data"));
}
#[test]
fn zone_try_from_rejects_invalid_forwarder_protocol() {
let data = zone_data("invalid-forwarder.test", vec![], vec!["http://1.1.1.1:53"]);
let err = Zone::try_from(&data).expect_err("unsupported forwarder should fail");
assert!(err.to_string().contains("unsupported") || err.to_string().contains("protocol"));
}
#[test]
fn empty_zone_creates_no_zone_handler() -> anyhow::Result<()> {
let zone = Zone::try_from(&zone_data("empty.test", vec![], vec![]))?;
assert!(zone.create_memory_zone_handler().is_none());
assert!(zone.create_forward_zone_handler().is_none());
Ok(())
}
#[test]
fn zone_roundtrip_preserves_records_and_forwarders() -> anyhow::Result<()> {
let zone = Zone::try_from(&zone_data(
"roundtrip.test",
vec!["www 0 IN A 123.123.123.123", "app IN CNAME www"],
vec!["udp://1.1.1.1:53", "tcp://8.8.8.8:53"],
))?;
assert_eq!(zone.iter_records().count(), 2);
assert_eq!(zone.forward.as_ref().unwrap().name_servers.len(), 2);
let serialized = ZoneData::from(zone.clone());
let reparsed = Zone::try_from(&serialized)?;
assert_eq!(reparsed.origin.to_string(), "roundtrip.test.");
assert_eq!(reparsed.iter_records().count(), 2);
assert_eq!(reparsed.forward.as_ref().unwrap().name_servers.len(), 2);
Ok(())
}
#[test]
fn zone_group_into_groups_merges_same_origin() -> anyhow::Result<()> {
let zones: ZoneGroup = vec![
Zone::try_from(&zone_data("same.test", vec!["@ IN A 10.0.0.1"], vec![]))?,
Zone::try_from(&zone_data("other.test", vec!["@ IN A 10.0.0.2"], vec![]))?,
Zone::try_from(&zone_data("same.test", vec![], vec!["udp://1.1.1.1:53"]))?,
]
.into();
let groups = zones.into_groups();
assert_eq!(groups.len(), 2);
assert_eq!(
groups
.get(&LowerName::from_str("same.test.")?)
.unwrap()
.len(),
2
);
assert_eq!(
groups
.get(&LowerName::from_str("other.test.")?)
.unwrap()
.len(),
1
);
Ok(())
}
#[test]
fn zone_group_iter_zone_handlers_returns_memory_and_forward() -> anyhow::Result<()> {
let zones: ZoneGroup = vec![Zone::try_from(&zone_data(
"zone-handler.test",
vec!["@ IN A 10.0.0.10"],
vec!["udp://1.1.1.1:53"],
))?]
.into();
let zone_handlers = zones.iter_zone_handlers().collect::<Vec<_>>();
assert_eq!(zone_handlers.len(), 2);
Ok(())
}
#[test]
fn zone_system_builds_root_forwarder() {
let zone = Zone::system();
assert_eq!(zone.origin.to_string(), ".");
assert!(zone.forward.is_some());
assert!(zone.create_forward_zone_handler().is_some());
}
#[tokio::test]
async fn catalog_lookup_returns_a_record_from_memory_zone_handler() -> anyhow::Result<()> {
let zones: ZoneGroup = vec![Zone::try_from(&zone_data(
"memory.test",
vec!["@ IN A 10.20.30.40"],
vec![],
))?]
.into();
let catalog = build_catalog(zones);
let (rcode, message) = lookup_message(&catalog, "memory.test.", RecordType::A).await?;
assert_eq!(rcode, ResponseCode::NoError);
let message = message.expect("response should exist");
assert!(has_a_answer(&message, Ipv4Addr::new(10, 20, 30, 40)));
Ok(())
}
#[tokio::test]
async fn catalog_lookup_returns_refused_when_zone_is_missing() -> anyhow::Result<()> {
let zones: ZoneGroup = vec![Zone::try_from(&zone_data(
"present.test",
vec!["@ IN A 10.20.30.41"],
vec![],
))?]
.into();
let catalog = build_catalog(zones);
let (rcode, _message) = lookup_message(&catalog, "absent.test.", RecordType::A).await?;
assert_eq!(rcode, ResponseCode::Refused);
Ok(())
}
#[tokio::test]
async fn catalog_lookup_forwards_on_nameexists() -> anyhow::Result<()> {
let upstream = Zone::try_from(&zone_data(
"forward-aaaa.test",
vec!["host 60 IN AAAA 2001:db8::1"],
vec![],
))?;
let mut upstream_catalog = Catalog::new();
upstream_catalog.upsert(
upstream.origin.clone(),
vec![upstream.create_memory_zone_handler().unwrap()],
);
let socket = UdpSocket::bind("127.0.0.1:0").await?;
let upstream_addr = socket.local_addr()?;
let mut server = Server::new(upstream_catalog);
server.register_socket(socket);
let upstream_handle = tokio::spawn(async move {
let _ = server.block_until_done().await;
});
let zones: ZoneGroup = vec![Zone::try_from(&zone_data(
"forward-aaaa.test",
vec!["host IN A 10.20.30.40"],
vec![&format!("udp://{}", upstream_addr)],
))?]
.into();
let catalog = build_catalog(zones);
let (rcode, message) =
lookup_message(&catalog, "host.forward-aaaa.test.", RecordType::AAAA).await?;
assert_eq!(rcode, ResponseCode::NoError);
let message = message.expect("response should exist");
assert!(has_aaaa_answer(&message, "2001:db8::1".parse()?));
upstream_handle.abort();
let _ = upstream_handle.await;
Ok(())
}
#[tokio::test]
async fn catalog_lookup_forwards_on_nxdomain() -> anyhow::Result<()> {
let upstream = Zone::try_from(&zone_data(
"forward-nxdomain.test",
vec!["missing 60 IN A 203.0.113.55"],
vec![],
))?;
let mut upstream_catalog = Catalog::new();
upstream_catalog.upsert(
upstream.origin.clone(),
vec![upstream.create_memory_zone_handler().unwrap()],
);
let socket = UdpSocket::bind("127.0.0.1:0").await?;
let upstream_addr = socket.local_addr()?;
let mut server = Server::new(upstream_catalog);
server.register_socket(socket);
let upstream_handle = tokio::spawn(async move {
let _ = server.block_until_done().await;
});
let zones: ZoneGroup = vec![Zone::try_from(&zone_data(
"forward-nxdomain.test",
vec!["present IN A 10.20.30.41"],
vec![&format!("udp://{}", upstream_addr)],
))?]
.into();
let catalog = build_catalog(zones);
let (rcode, message) =
lookup_message(&catalog, "missing.forward-nxdomain.test.", RecordType::A).await?;
assert_eq!(rcode, ResponseCode::NoError);
let message = message.expect("response should exist");
assert!(has_a_answer(&message, Ipv4Addr::new(203, 0, 113, 55)));
upstream_handle.abort();
let _ = upstream_handle.await;
Ok(())
}
#[tokio::test]
async fn catalog_lookup_falls_back_to_later_zone_handler_with_same_origin() -> anyhow::Result<()>
{
let zones: ZoneGroup = vec![
// First matching zone exists but does not contain the queried name.
Zone::try_from(&zone_data(
"fallback.test",
vec!["first IN A 10.20.30.1"],
vec![],
))?,
// Second matching zone should be queried as fallback and answer.
Zone::try_from(&zone_data(
"fallback.test",
vec!["target IN A 10.20.30.2"],
vec![],
))?,
]
.into();
let catalog = build_catalog(zones);
let (rcode, message) =
lookup_message(&catalog, "target.fallback.test.", RecordType::A).await?;
assert_eq!(rcode, ResponseCode::NoError);
assert!(has_a_answer(
&message.expect("response should exist"),
Ipv4Addr::new(10, 20, 30, 2)
));
Ok(())
}
#[tokio::test]
async fn catalog_lookup_does_not_fall_back_when_fallthrough_disabled() -> anyhow::Result<()> {
let zones: ZoneGroup = vec![
Zone::try_from(&zone_data_with_fallthrough(
"fallback-disabled.test",
vec!["first IN A 10.20.31.1"],
vec![],
hashset! {},
))?,
Zone::try_from(&zone_data_with_fallthrough(
"fallback-disabled.test",
vec!["target IN A 10.20.31.2"],
vec![],
hashset! {},
))?,
]
.into();
let catalog = build_catalog(zones);
let (rcode, message) =
lookup_message(&catalog, "target.fallback-disabled.test.", RecordType::A).await?;
assert_ne!(rcode, ResponseCode::NoError);
if let Some(message) = message.as_ref() {
assert!(!has_a_answer(message, Ipv4Addr::new(10, 20, 31, 2)));
}
Ok(())
}
#[tokio::test]
async fn catalog_forward_only_zone_queries_upstream() -> anyhow::Result<()> {
let (upstream_addr, upstream_handle) = start_upstream_server().await?;
let forward_zone = Zone::try_from(&zone_data(
"upstream.test",
vec![],
vec![&format!("udp://{}", upstream_addr)],
))?;
let catalog = build_catalog(vec![forward_zone].into());
let (rcode, message) =
lookup_message(&catalog, "from-forward.upstream.test.", RecordType::A).await?;
assert_eq!(rcode, ResponseCode::NoError);
assert!(has_a_answer(
&message.expect("response should exist"),
Ipv4Addr::new(203, 0, 113, 9)
));
upstream_handle.abort();
let _ = upstream_handle.await;
Ok(())
}
}
+20 -6
View File
@@ -193,8 +193,11 @@ struct PeerArgs {
#[derive(Subcommand, Debug)]
enum PeerSubCommand {
/// List connected peers
List,
/// Show public IPv6 address information
Ipv6,
/// List foreign networks discovered by this instance
ListForeign {
#[arg(
long,
@@ -203,6 +206,7 @@ enum PeerSubCommand {
)]
trusted_keys: bool,
},
/// List global foreign networks from the peer center
ListGlobalForeign,
}
@@ -214,16 +218,18 @@ struct RouteArgs {
#[derive(Subcommand, Debug)]
enum RouteSubCommand {
/// List routes propagated by peers
List,
/// Dump routes in CIDR format
Dump,
}
#[derive(Args, Debug)]
struct ConnectorArgs {
#[arg(short, long)]
#[arg(short, long, help = "filter connectors by virtual IPv4 address")]
ipv4: Option<String>,
#[arg(short, long)]
#[arg(short, long, help = "filter connectors by peer URL")]
peers: Vec<String>,
#[command(subcommand)]
@@ -242,6 +248,7 @@ enum ConnectorSubCommand {
#[arg(help = "connector url, e.g., tcp://1.2.3.4:11010")]
url: String,
},
/// List connectors
List,
}
@@ -283,6 +290,7 @@ struct AclArgs {
#[derive(Subcommand, Debug)]
enum AclSubCommand {
/// Show ACL rule hit statistics
Stats,
}
@@ -450,19 +458,25 @@ struct InstallArgs {
#[arg(long, default_value = env!("CARGO_PKG_DESCRIPTION"), help = "service description")]
description: String,
#[arg(long)]
#[arg(long, help = "display name shown by the service manager")]
display_name: Option<String>,
#[arg(long)]
#[arg(
long,
help = "whether to disable starting the service automatically on boot (true/false)"
)]
disable_autostart: Option<bool>,
#[arg(long)]
#[arg(
long,
help = "whether to disable automatic restart when the service fails (true/false)"
)]
disable_restart_on_failure: Option<bool>,
#[arg(long, help = "path to easytier-core binary")]
core_path: Option<PathBuf>,
#[arg(long)]
#[arg(long, help = "working directory for the easytier-core service")]
service_work_dir: Option<PathBuf>,
#[arg(
+45 -59
View File
@@ -4,7 +4,7 @@ use std::{
time::Duration,
};
use anyhow::Context;
use anyhow::{Context, anyhow, bail};
use bytes::Bytes;
use dashmap::DashMap;
use guarden::defer;
@@ -15,12 +15,13 @@ use kcp_sys::{
stream::KcpStream,
};
use prost::Message;
use tokio::{select, task::JoinSet};
use tokio::task::JoinSet;
use super::{
CidrSet,
tcp_proxy::{NatDstConnector, NatDstTcpConnector, TcpProxy},
};
use crate::utils::task::HedgeExt;
use crate::{
common::{
acl_processor::PacketInfo,
@@ -114,72 +115,57 @@ pub struct NatDstKcpConnector {
impl NatDstConnector for NatDstKcpConnector {
type DstStream = KcpStream;
async fn connect(&self, src: SocketAddr, nat_dst: SocketAddr) -> Result<Self::DstStream> {
async fn connect(
&self,
src: SocketAddr,
nat_dst: SocketAddr,
) -> anyhow::Result<Self::DstStream> {
let peer_mgr = self
.peer_mgr
.upgrade()
.ok_or_else(|| anyhow!("peer manager is not available"))?;
let dst_peer = {
let SocketAddr::V4(addr) = nat_dst else {
bail!("ipv6 is not supported");
};
peer_mgr
.get_peer_map()
.get_peer_id_by_ipv4(addr.ip())
.await
.ok_or_else(|| anyhow!("no peer found for nat dst: {}", nat_dst))?
};
tracing::trace!(?nat_dst, ?dst_peer, "kcp nat");
let conn_data = KcpConnData {
src: Some(src.into()),
dst: Some(nat_dst.into()),
};
let Some(peer_mgr) = self.peer_mgr.upgrade() else {
return Err(anyhow::anyhow!("peer manager is not available").into());
};
let stream = (0..5)
.map(|_| {
let kcp_endpoint = self.kcp_endpoint.clone();
let my_peer_id = peer_mgr.my_peer_id();
let dst_peer_id = match nat_dst {
SocketAddr::V4(addr) => peer_mgr.get_peer_map().get_peer_id_by_ipv4(addr.ip()).await,
SocketAddr::V6(_) => return Err(anyhow::anyhow!("ipv6 is not supported").into()),
};
async move {
let conn_id = kcp_endpoint
.connect(
Duration::from_secs(10),
my_peer_id,
dst_peer,
Bytes::from(conn_data.encode_to_vec()),
)
.await?;
let Some(dst_peer) = dst_peer_id else {
return Err(anyhow::anyhow!("no peer found for nat dst: {}", nat_dst).into());
};
tracing::trace!("kcp nat dst: {:?}, dst peers: {:?}", nat_dst, dst_peer);
let mut connect_tasks: JoinSet<std::result::Result<ConnId, anyhow::Error>> = JoinSet::new();
let mut retry_remain = 5;
loop {
select! {
Some(Ok(Ok(ret))) = connect_tasks.join_next() => {
// just wait for the previous connection to finish
let stream = KcpStream::new(&self.kcp_endpoint, ret)
.ok_or(anyhow::anyhow!("failed to create kcp stream"))?;
return Ok(stream);
KcpStream::new(&kcp_endpoint, conn_id).context("failed to create kcp stream")
}
_ = tokio::time::sleep(Duration::from_millis(200)), if !connect_tasks.is_empty() && retry_remain > 0 => {
// no successful connection yet, trigger another connection attempt
}
else => {
// got error in connect_tasks, continue to retry
if retry_remain == 0 && connect_tasks.is_empty() {
break;
}
}
}
})
.hedge(Duration::from_millis(200))
.await
.context("failed to connect to peer")?;
// create a new connection task
if retry_remain == 0 {
continue;
}
retry_remain -= 1;
let kcp_endpoint = self.kcp_endpoint.clone();
let my_peer_id = peer_mgr.my_peer_id();
let conn_data_clone = conn_data;
connect_tasks.spawn(async move {
kcp_endpoint
.connect(
Duration::from_secs(10),
my_peer_id,
dst_peer,
Bytes::from(conn_data_clone.encode_to_vec()),
)
.await
.with_context(|| format!("failed to connect to nat dst: {}", nat_dst))
});
}
Err(anyhow::anyhow!("failed to connect to nat dst: {}", nat_dst).into())
Ok(stream)
}
fn check_packet_from_peer_fast(&self, _cidr_set: &CidrSet, _global_ctx: &GlobalCtx) -> bool {
+107 -59
View File
@@ -18,17 +18,20 @@ use crate::tunnel::packet_def::{
PacketType, PeerManagerHeader, TAIL_RESERVED_SIZE, ZCPacket, ZCPacketType,
};
use crate::tunnel::quic::{client_config, endpoint_config, server_config};
use anyhow::{Context, Error, anyhow};
use crate::utils::task::HedgeExt;
use anyhow::{Context, Error, anyhow, bail, ensure};
use atomic_refcell::AtomicRefCell;
use bytes::{BufMut, Bytes, BytesMut};
use dashmap::DashMap;
use derivative::Derivative;
use derive_more::{Constructor, Deref, DerefMut, From, Into};
use guarden::defer;
use moka::future::Cache;
use prost::Message;
use quinn::udp::{EcnCodepoint, RecvMeta, Transmit};
use quinn::{
AsyncUdpSocket, Endpoint, RecvStream, SendStream, StreamId, UdpPoller, default_runtime,
AsyncUdpSocket, Connection, ConnectionError, Endpoint, RecvStream, SendStream, StreamId,
UdpPoller, WriteError, default_runtime,
};
use std::cmp::min;
use std::future::Future;
@@ -43,8 +46,8 @@ use tokio::io::{AsyncReadExt, Join, join};
use tokio::sync::mpsc::error::TrySendError;
use tokio::sync::mpsc::{Receiver, Sender, channel};
use tokio::task::JoinSet;
use tokio::time::{Instant, timeout};
use tokio::{join, pin, select};
use tokio::time::timeout;
use tokio::{join, select};
use tokio_util::sync::PollSender;
use tracing::{debug, error, info, instrument, trace, warn};
@@ -279,6 +282,7 @@ impl From<(SendStream, RecvStream)> for QuicStream {
pub struct NatDstQuicConnector {
pub(crate) endpoint: Endpoint,
pub(crate) peer_mgr: Weak<PeerManager>,
pub(crate) conn_map: Cache<PeerId, Connection>,
}
#[async_trait::async_trait]
@@ -289,21 +293,25 @@ impl NatDstConnector for NatDstQuicConnector {
&self,
src: SocketAddr,
nat_dst: SocketAddr,
) -> crate::common::error::Result<Self::DstStream> {
let Some(peer_mgr) = self.peer_mgr.upgrade() else {
return Err(anyhow::anyhow!("peer manager is not available").into());
) -> anyhow::Result<Self::DstStream> {
let peer_mgr = self
.peer_mgr
.upgrade()
.ok_or_else(|| anyhow!("peer manager is not available"))?;
let dst_peer = {
let SocketAddr::V4(addr) = nat_dst else {
bail!("ipv6 is not supported");
};
peer_mgr
.get_peer_map()
.get_peer_id_by_ipv4(addr.ip())
.await
.ok_or_else(|| anyhow!("no peer found for nat dst: {}", nat_dst))?
};
let Some(dst_peer_id) = (match nat_dst {
SocketAddr::V4(addr) => peer_mgr.get_peer_map().get_peer_id_by_ipv4(addr.ip()).await,
SocketAddr::V6(_) => return Err(anyhow::anyhow!("ipv6 is not supported").into()),
}) else {
return Err(anyhow::anyhow!("no peer found for nat dst: {}", nat_dst).into());
};
tracing::trace!(?nat_dst, ?dst_peer, "quic nat");
trace!("quic nat dst: {:?}, dst peers: {:?}", nat_dst, dst_peer_id);
let addr = QuicAddr::new(dst_peer_id, PacketType::QuicSrc).into();
let header = {
let conn_data = QuicConnData {
src: Some(src.into()),
@@ -311,61 +319,90 @@ impl NatDstConnector for NatDstQuicConnector {
};
let len = conn_data.encoded_len();
if len > (u16::MAX as usize) {
return Err(anyhow!("conn data too large: {:?}", len).into());
}
ensure!(len <= u16::MAX as usize, "conn data too large: {len}");
let mut buf = BytesMut::with_capacity(2 + len);
buf.put_u16(len as u16);
conn_data.encode(&mut buf).unwrap();
conn_data.encode(&mut buf)?;
buf.freeze()
};
let mut connect_tasks = JoinSet::<Result<QuicStream, Error>>::new();
let connect = |tasks: &mut JoinSet<_>| {
let endpoint = self.endpoint.clone();
let header = header.clone();
let reconnect = || async move {
self.conn_map.invalidate(&dst_peer).await;
tasks.spawn(async move {
let connection = endpoint.connect(addr, "")?.await?;
let mut stream: QuicStream = connection.open_bi().await?.into();
stream.writer_mut().write_chunk(header).await?;
Ok(stream)
});
let connect = (0..5)
.map(|_| {
let endpoint = self.endpoint.clone();
async move {
endpoint
.connect(QuicAddr::new(dst_peer, PacketType::QuicSrc).into(), "")
.context("failed to create connection")?
.await
.context("connection failed")
}
})
.hedge(Duration::from_millis(200));
self.conn_map
.try_get_with(dst_peer, connect)
.await
.context("failed to connect to peer")
};
connect(&mut connect_tasks);
let mut reconnected = false;
let timer = tokio::time::sleep(Duration::from_millis(200));
pin!(timer);
let mut connection = if let Some(connection) = self.conn_map.get(&dst_peer).await
&& connection.close_reason().is_none()
{
connection
} else {
reconnected = true;
reconnect().await?
};
let mut retry_remain = 5;
loop {
select! {
Some(result) = connect_tasks.join_next() => {
match result {
Ok(Ok(stream)) => return Ok(stream.into()),
_ => {
if connect_tasks.is_empty() {
if retry_remain == 0 {
return Err(anyhow!("failed to connect to nat dst: {:?}", nat_dst).into())
}
let is_retryable = |error: &ConnectionError| {
matches!(
error,
ConnectionError::ConnectionClosed(_)
| ConnectionError::ApplicationClosed(_)
| ConnectionError::Reset
| ConnectionError::TimedOut
)
};
let mut retry = !reconnected;
let header = header.clone();
let result = async {
let mut stream: QuicStream = connection
.open_bi()
.await
.inspect_err(|error| retry &= is_retryable(error))?
.into();
stream
.writer_mut()
.write_chunk(header)
.await
.inspect_err(|error| {
retry &= matches!(error, WriteError::ConnectionLost(error) if is_retryable(error))
})?;
Ok(stream.into())
}
.await;
retry_remain -= 1;
connect(&mut connect_tasks);
timer.as_mut().reset(Instant::now() + Duration::from_millis(200))
}
}
}
}
_ = &mut timer, if retry_remain > 0 => {
retry_remain -= 1;
connect(&mut connect_tasks);
timer.as_mut().reset(Instant::now() + Duration::from_millis(200));
if let Err(error) = &result {
if retry {
debug!(?error, "failed to open quic stream, retrying...");
reconnected = true;
connection = reconnect().await?;
continue;
} else {
self.conn_map.invalidate(&dst_peer).await;
}
}
break result;
}
}
@@ -595,10 +632,17 @@ impl QuicStreamReceiver {
}
};
match Self::establish_stream(stream, ctx.clone()).await {
Ok(stream) => drop(tasks.spawn(stream)),
Err(e) => warn!("failed to establish quic stream from {:?}: {:?}", connection.remote_address(), e),
}
let ctx = ctx.clone();
tasks.spawn(async move {
match Self::establish_stream(stream, ctx).await {
Ok(transfer_fut) => {
if let Err(e) = transfer_fut.await {
warn!("quic stream transfer error: {:?}", e);
}
}
Err(e) => warn!("failed to establish quic stream: {:?}", e),
}
});
}
res = tasks.join_next(), if !tasks.is_empty() => {
@@ -816,7 +860,7 @@ impl QuicProxy {
Arc::new(socket),
default_runtime().unwrap(),
)
.unwrap();
.unwrap(); // TODO: maybe a different transport config
endpoint.set_default_client_config(client_config());
self.endpoint = Some(endpoint.clone());
@@ -845,6 +889,10 @@ impl QuicProxy {
NatDstQuicConnector {
endpoint: endpoint.clone(),
peer_mgr: Arc::downgrade(&peer_mgr),
conn_map: Cache::builder()
.max_capacity(u8::MAX.into()) // cf. quinn transport config (max_concurrent_bidi_streams)
.time_to_idle(Duration::from_secs(600)) // cf. quinn transport config (max_idle_timeout)
.build(),
},
));
+1 -1
View File
@@ -240,7 +240,7 @@ impl AsyncTcpConnector for Socks5KcpConnector {
let ret = c
.connect(self.src_addr, addr)
.await
.map_err(|e| super::fast_socks5::SocksError::Other(e.into()))?;
.map_err(super::fast_socks5::SocksError::Other)?;
Ok(SocksTcpStream::Kcp(ret))
}
}
+8 -9
View File
@@ -44,7 +44,7 @@ use super::tokio_smoltcp::{self, Net, NetConfig, channel_device};
pub(crate) trait NatDstConnector: Send + Sync + Clone + 'static {
type DstStream: AsyncRead + AsyncWrite + Unpin + Send;
async fn connect(&self, src: SocketAddr, dst: SocketAddr) -> Result<Self::DstStream>;
async fn connect(&self, src: SocketAddr, dst: SocketAddr) -> anyhow::Result<Self::DstStream>;
fn check_packet_from_peer_fast(&self, cidr_set: &CidrSet, global_ctx: &GlobalCtx) -> bool;
fn check_packet_from_peer(
&self,
@@ -63,14 +63,13 @@ pub struct NatDstTcpConnector;
#[async_trait::async_trait]
impl NatDstConnector for NatDstTcpConnector {
type DstStream = TcpStream;
async fn connect(&self, _src: SocketAddr, nat_dst: SocketAddr) -> Result<Self::DstStream> {
let socket = match TcpSocket::new_v4() {
Ok(s) => s,
Err(error) => {
log::error!(?error, "create v4 socket failed");
return Err(error.into());
}
};
async fn connect(
&self,
_src: SocketAddr,
nat_dst: SocketAddr,
) -> anyhow::Result<Self::DstStream> {
let socket = TcpSocket::new_v4()
.inspect_err(|error| log::error!(?error, "create v4 socket failed"))?;
let stream = timeout(Duration::from_secs(10), socket.connect(nat_dst))
.await?
@@ -1,111 +0,0 @@
use std::{sync::Arc, time::Duration};
use tokio::task::JoinSet;
use crate::{
peers::peer_manager::PeerManager,
proto::{
api::instance::Route,
common::Void,
magic_dns::{
HandshakeRequest, MagicDnsServerRpc, MagicDnsServerRpcClientFactory,
UpdateDnsRecordRequest,
},
rpc_impl::standalone::StandAloneClient,
rpc_types::controller::BaseController,
},
tunnel::tcp::TcpTunnelConnector,
};
use super::MAGIC_DNS_INSTANCE_ADDR;
pub struct MagicDnsClientInstance {
rpc_client: StandAloneClient<TcpTunnelConnector>,
rpc_stub: Option<Box<dyn MagicDnsServerRpc<Controller = BaseController> + Send>>,
peer_mgr: Arc<PeerManager>,
tasks: JoinSet<()>,
}
impl MagicDnsClientInstance {
pub async fn new(peer_mgr: Arc<PeerManager>) -> Result<Self, anyhow::Error> {
let tcp_connector = TcpTunnelConnector::new(MAGIC_DNS_INSTANCE_ADDR.parse().unwrap());
let mut rpc_client = StandAloneClient::new(tcp_connector);
let rpc_stub = rpc_client
.scoped_client::<MagicDnsServerRpcClientFactory<BaseController>>("".to_string())
.await?;
Ok(MagicDnsClientInstance {
rpc_client,
rpc_stub: Some(rpc_stub),
peer_mgr,
tasks: JoinSet::new(),
})
}
async fn update_dns_task(
peer_mgr: Arc<PeerManager>,
rpc_stub: Box<dyn MagicDnsServerRpc<Controller = BaseController> + Send>,
) -> Result<(), anyhow::Error> {
let mut prev_last_update = None;
rpc_stub
.handshake(BaseController::default(), HandshakeRequest::default())
.await?;
loop {
rpc_stub
.heartbeat(BaseController::default(), Void::default())
.await?;
let last_update = peer_mgr.get_route_peer_info_last_update_time().await;
if Some(last_update) == prev_last_update {
tokio::time::sleep(Duration::from_millis(500)).await;
continue;
}
let mut routes = peer_mgr.list_routes().await;
// add self as a route
let ctx = peer_mgr.get_global_ctx();
routes.push(Route {
hostname: ctx.get_hostname(),
ipv4_addr: ctx.get_ipv4().map(Into::into),
..Default::default()
});
// Use configured tld_dns_zone (always set by default)
let flags = ctx.config.get_flags();
let req = UpdateDnsRecordRequest {
routes,
zone: flags.tld_dns_zone.clone(),
};
tracing::debug!(
"MagicDnsClientInstance::update_dns_task: update dns records: {:?}",
req
);
rpc_stub
.update_dns_record(BaseController::default(), req)
.await?;
let last_update_after_rpc = peer_mgr.get_route_peer_info_last_update_time().await;
if last_update_after_rpc == last_update {
prev_last_update = Some(last_update);
}
}
}
pub async fn run_and_wait(&mut self) {
let rpc_stub = self.rpc_stub.take().unwrap();
let peer_mgr = self.peer_mgr.clone();
self.tasks.spawn(async move {
let ret = Self::update_dns_task(peer_mgr, rpc_stub).await;
if let Err(e) = ret {
tracing::error!("MagicDnsServerInstanceData::run_and_wait: {:?}", e);
}
});
tokio::select! {
_ = self.tasks.join_next() => {
tracing::warn!("MagicDnsServerInstanceData::run_and_wait: dns record update task exited");
}
_ = self.rpc_client.wait() => {
tracing::warn!("MagicDnsServerInstanceData::run_and_wait: rpc client exited");
}
}
}
}
-193
View File
@@ -1,193 +0,0 @@
use hickory_proto::rr;
use hickory_proto::rr::RData;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr};
use std::str::FromStr;
use std::time::Duration;
#[derive(Serialize, Deserialize, Debug, Clone, derive_builder::Builder)]
pub struct RunConfig {
general: GeneralConfig,
#[builder(default = HashMap::new())]
zones: Zone,
#[builder(default = Vec::new())]
#[serde(default)]
excluded_forward_nameservers: Vec<IpAddr>,
}
impl RunConfig {
pub fn general(&self) -> &GeneralConfig {
&self.general
}
pub fn zones(&self) -> &Zone {
&self.zones
}
pub fn excluded_forward_nameservers(&self) -> &Vec<IpAddr> {
&self.excluded_forward_nameservers
}
}
#[derive(Serialize, Deserialize, Debug, Clone, derive_builder::Builder)]
pub struct GeneralConfig {
#[builder(setter(into, strip_option), default = None)]
listen_tcp: Option<String>,
#[builder(setter(into, strip_option), default = None)]
listen_udp: Option<String>,
}
impl GeneralConfig {
pub fn listen_tcp(&self) -> &Option<String> {
&self.listen_tcp
}
pub fn listen_udp(&self) -> &Option<String> {
&self.listen_udp
}
}
pub type Zone = HashMap<String, Vec<Record>>; // domain -> records
pub type RecordType = rr::RecordType;
#[derive(Serialize, Deserialize, Debug, Clone, derive_builder::Builder)]
pub struct Record {
#[serde(rename = "type")]
rr_type: RecordType,
name: String,
value: String,
#[serde(with = "humantime_serde")]
ttl: Duration,
}
impl Record {
pub fn name(&self) -> anyhow::Result<rr::Name> {
let name = rr::Name::from_str(self.name.as_str())?;
Ok(name)
}
fn rr_type(&self) -> rr::RecordType {
self.rr_type
}
}
impl TryFrom<Record> for rr::Record {
type Error = anyhow::Error;
fn try_from(value: Record) -> Result<Self, Self::Error> {
let r: rr::Record = (&value).try_into()?;
Ok(r)
}
}
impl TryFrom<&Record> for rr::Record {
type Error = anyhow::Error;
fn try_from(value: &Record) -> Result<Self, Self::Error> {
let name = value.name()?;
let mut record = Self::update0(name, value.ttl.as_secs() as u32, value.rr_type());
record.set_dns_class(rr::DNSClass::IN);
match value.rr_type {
RecordType::A => {
let addr: Ipv4Addr = value.value.parse()?;
record.set_data(RData::A(rr::rdata::a::A(addr)));
}
RecordType::SOA => {
let soa = value.value.split_whitespace().collect::<Vec<_>>();
if soa.len() != 7 {
return Err(anyhow::anyhow!("invalid SOA record"));
}
let mname = rr::Name::from_str(soa[0])?;
let rname = rr::Name::from_str(soa[1])?;
let serial: u32 = soa[2].parse()?;
let refresh: u32 = soa[3].parse()?;
let retry: u32 = soa[4].parse()?;
let expire: u32 = soa[5].parse()?;
let minimum: u32 = soa[6].parse()?;
record.set_data(RData::SOA(rr::rdata::soa::SOA::new(
mname,
rname,
serial,
refresh.try_into().unwrap(),
retry.try_into().unwrap(),
expire.try_into().unwrap(),
minimum,
)));
}
_ => todo!(),
}
Ok(record)
}
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::anyhow;
#[tokio::test]
async fn it_works() -> anyhow::Result<()> {
let text = r#"
[general]
listen_tcp = "127.0.0.1:5300"
listen_udp = "127.0.0.1:5353"
[[zones."et.internal"]]
type = "A"
name = "www"
value = "123.123.123.123"
ttl = "60s"
[[zones."et.top"]]
type = "A"
name = "@"
value = "100.100.100.100"
ttl = "61s"
"#;
let config = toml::from_str::<RunConfig>(text)?;
assert_eq!(
config.general.listen_tcp().clone().unwrap(),
"127.0.0.1:5300"
);
assert_eq!(
config.general.listen_udp().clone().unwrap(),
"127.0.0.1:5353"
);
assert_eq!(config.zones.len(), 2);
let (domain, records) = config
.zones
.get_key_value("et.internal")
.ok_or(anyhow!("et.internal not found"))?;
assert_eq!(domain, "et.internal");
assert_eq!(records.len(), 1);
let record = &records[0];
assert_eq!(record.rr_type, RecordType::A);
assert_eq!(record.name, "www");
assert_eq!(record.value, "123.123.123.123");
assert_eq!(record.ttl.as_secs(), 60);
let (domain, records) = config
.zones
.get_key_value("et.top")
.ok_or(anyhow!("et.top not found"))?;
assert_eq!(domain, "et.top");
assert_eq!(records.len(), 1);
let record = &records[0];
assert_eq!(record.rr_type, RecordType::A);
assert_eq!(record.name, "@");
assert_eq!(record.value, "100.100.100.100");
assert_eq!(record.ttl.as_secs(), 61);
Ok(())
}
}
-21
View File
@@ -1,21 +0,0 @@
// This module is copy and modified from https://github.com/fanyang89/libdns
#[cfg(feature = "magic-dns")]
pub(crate) mod config;
#[cfg(feature = "magic-dns")]
pub(crate) mod server;
#[cfg(feature = "magic-dns")]
pub mod client_instance;
#[cfg(feature = "magic-dns")]
pub mod runner;
#[cfg(feature = "magic-dns")]
pub mod server_instance;
#[cfg(feature = "magic-dns")]
pub mod system_config;
#[cfg(all(test, feature = "tun", feature = "magic-dns"))]
mod tests;
pub static MAGIC_DNS_INSTANCE_ADDR: &str = "tcp://127.0.0.1:49813";
pub static MAGIC_DNS_FAKE_IP: &str = "100.100.100.101";
pub static DEFAULT_ET_DNS_ZONE: &str = "et.net.";
@@ -1,93 +0,0 @@
use cidr::Ipv4Inet;
use tokio_util::sync::CancellationToken;
use crate::peers::peer_manager::PeerManager;
use std::{net::Ipv4Addr, sync::Arc, time::Duration};
use super::{client_instance::MagicDnsClientInstance, server_instance::MagicDnsServerInstance};
static DEFAULT_ET_DNS_ZONE: &str = "et.net.";
pub struct DnsRunner {
client: Option<MagicDnsClientInstance>,
server: Option<MagicDnsServerInstance>,
peer_mgr: Arc<PeerManager>,
tun_dev: Option<String>,
tun_inet: Ipv4Inet,
fake_ip: Ipv4Addr,
}
impl DnsRunner {
pub fn new(
peer_mgr: Arc<PeerManager>,
tun_dev: Option<String>,
tun_inet: Ipv4Inet,
fake_ip: Ipv4Addr,
) -> Self {
Self {
client: None,
server: None,
peer_mgr,
tun_dev,
tun_inet,
fake_ip,
}
}
async fn clean_env(&mut self) {
if let Some(server) = self.server.take() {
server.clean_env().await;
}
self.client.take();
}
async fn run_once(&mut self) -> anyhow::Result<()> {
// try server first
match MagicDnsServerInstance::new(
self.peer_mgr.clone(),
self.tun_dev.clone(),
self.tun_inet,
self.fake_ip,
)
.await
{
Ok(server) => {
self.server = Some(server);
tracing::info!("DnsRunner::run_once: server started");
}
Err(e) => {
tracing::error!("DnsRunner::run_once: {:?}", e);
}
}
// every runner must run a client
let client = MagicDnsClientInstance::new(self.peer_mgr.clone()).await?;
self.client = Some(client);
self.client.as_mut().unwrap().run_and_wait().await;
Err(anyhow::anyhow!("Client instance exit"))
}
pub async fn run(&mut self, canel_token: CancellationToken) {
loop {
tracing::info!("DnsRunner::run: start");
tokio::select! {
_ = canel_token.cancelled() => {
self.clean_env().await;
tracing::info!("DnsRunner::run: cancelled");
return;
}
ret = self.run_once() => {
self.clean_env().await;
if let Err(e) = ret {
tracing::error!("DnsRunner::run: {:?}", e);
} else {
tracing::info!("DnsRunner::run: unexpected exit, server may be down");
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
}
}
}
}
-338
View File
@@ -1,338 +0,0 @@
use anyhow::{Context, Result};
use hickory_proto::op::Edns;
use hickory_proto::rr;
use hickory_proto::rr::LowerName;
use hickory_resolver::config::ResolverOpts;
use hickory_resolver::name_server::TokioConnectionProvider;
use hickory_resolver::system_conf::read_system_conf;
use hickory_server::ServerFuture;
use hickory_server::authority::{AuthorityObject, Catalog, ZoneType};
use hickory_server::server::{Request, RequestHandler, ResponseHandler, ResponseInfo};
use hickory_server::store::forwarder::ForwardConfig;
use hickory_server::store::{forwarder::ForwardAuthority, in_memory::InMemoryAuthority};
use std::io;
use std::net::SocketAddr;
use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;
use tokio::net::{TcpListener, UdpSocket};
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
use tokio::task::JoinSet;
use crate::common::dns::get_default_resolver_config;
use super::config::{GeneralConfig, Record, RunConfig};
pub struct Server {
server: ServerFuture<CatalogRequestHandler>,
catalog: Arc<RwLock<Catalog>>,
general_config: GeneralConfig,
udp_local_addr: Option<SocketAddr>,
tcp_local_addr: Option<SocketAddr>,
tasks: JoinSet<()>,
}
struct CatalogRequestHandler {
catalog: Arc<RwLock<Catalog>>,
}
impl CatalogRequestHandler {
fn new(catalog: Arc<RwLock<Catalog>>) -> CatalogRequestHandler {
// let system_conf = read_system_conf();
// let recursor = match system_conf {
// Ok((conf, _)) => RecursorBuilder::default().build(conf),
// Err(_) => RecursorBuilder::default().build(get_default_resolver_config()),
// }
// // policy is security unware, this will never return an error
// .unwrap();
Self { catalog }
}
}
#[async_trait::async_trait]
impl RequestHandler for CatalogRequestHandler {
async fn handle_request<R: ResponseHandler>(
&self,
request: &Request,
response_handle: R,
) -> ResponseInfo {
self.catalog
.read()
.await
.handle_request(request, response_handle)
.await
}
}
pub fn build_authority(domain: &str, records: &[Record]) -> Result<InMemoryAuthority> {
let zone = rr::Name::from_str(domain)?;
let mut authority = InMemoryAuthority::empty(zone, ZoneType::Primary, false);
for record in records.iter() {
let r = record.try_into()?;
authority.upsert_mut(r, 0);
}
Ok(authority)
}
impl Server {
pub fn new(config: RunConfig) -> Self {
Self::try_new(config).unwrap()
}
fn try_new(config: RunConfig) -> Result<Self> {
let mut catalog = Catalog::new();
for (domain, records) in config.zones().iter() {
let zone = rr::Name::from_str(domain.as_str())?;
let authroty = build_authority(domain, records)?;
catalog.upsert(zone.clone().into(), vec![Arc::new(authroty)]);
}
// use forwarder authority for the root zone
let system_conf =
read_system_conf().unwrap_or((get_default_resolver_config(), ResolverOpts::default()));
let forward_config = ForwardConfig {
name_servers: system_conf
.0
.name_servers()
.iter()
.filter(|&x| {
!config
.excluded_forward_nameservers()
.contains(&x.socket_addr.ip())
})
.cloned()
.collect::<Vec<_>>()
.into(),
options: Some(system_conf.1),
};
let auth = ForwardAuthority::builder_with_config(
forward_config,
TokioConnectionProvider::default(),
)
.build()
.unwrap();
catalog.upsert(rr::Name::from_str(".")?.into(), vec![Arc::new(auth)]);
let catalog = Arc::new(RwLock::new(catalog));
let handler = CatalogRequestHandler::new(catalog.clone());
let server = ServerFuture::new(handler);
Ok(Self {
server,
catalog,
general_config: config.general().clone(),
udp_local_addr: None,
tcp_local_addr: None,
tasks: JoinSet::new(),
})
}
pub fn udp_local_addr(&self) -> Option<SocketAddr> {
self.udp_local_addr
}
pub fn tcp_local_addr(&self) -> Option<SocketAddr> {
self.tcp_local_addr
}
pub async fn register_udp_socket(&mut self, address: String) -> Result<SocketAddr> {
let bind_addr = SocketAddr::from_str(&address)
.with_context(|| format!("DNS Server failed to parse address {}", address))?;
let socket = socket2::Socket::new(
socket2::Domain::IPV4,
socket2::Type::DGRAM,
Some(socket2::Protocol::UDP),
)
.with_context(|| {
format!(
"DNS Server failed to create UDP socket for address {}",
address
)
})?;
socket2::SockRef::from(&socket)
.set_reuse_address(true)
.with_context(|| {
format!(
"DNS Server failed to set reuse address on socket {}",
address
)
})?;
socket.bind(&bind_addr.into()).with_context(|| {
format!("DNS Server failed to bind socket to address {}", bind_addr)
})?;
socket
.set_nonblocking(true)
.with_context(|| "DNS Server failed to set socket to non-blocking".to_string())?;
let socket = UdpSocket::from_std(socket.into()).with_context(|| {
format!(
"DNS Server failed to convert socket to UdpSocket for address {}",
address
)
})?;
let local_addr = socket
.local_addr()
.with_context(|| "DNS Server failed to get local address".to_string())?;
self.server.register_socket(socket);
Ok(local_addr)
}
pub async fn run(&mut self) -> Result<()> {
if let Some(address) = self.general_config.listen_tcp() {
let tcp_listener = TcpListener::bind(address.clone())
.await
.with_context(|| format!("DNS Server failed to bind TCP address {}", address))?;
self.tcp_local_addr = Some(tcp_listener.local_addr()?);
self.server
.register_listener(tcp_listener, Duration::from_secs(5));
}
if let Some(address) = self.general_config.listen_udp() {
let local_addr = self.register_udp_socket(address.clone()).await?;
self.udp_local_addr = Some(local_addr);
};
Ok(())
}
pub async fn shutdown(&mut self) -> Result<()> {
self.server.shutdown_gracefully().await?;
Ok(())
}
pub async fn upsert(&self, name: LowerName, authority: Arc<dyn AuthorityObject>) {
self.catalog.write().await.upsert(name, vec![authority]);
}
pub async fn remove(&self, name: &LowerName) -> Option<Vec<Arc<dyn AuthorityObject>>> {
self.catalog.write().await.remove(name)
}
pub async fn update<R: ResponseHandler>(
&self,
update: &Request,
response_edns: Option<Edns>,
response_handle: R,
) -> io::Result<ResponseInfo> {
self.catalog
.write()
.await
.update(update, response_edns, response_handle)
.await
}
pub async fn contains(&self, name: &LowerName) -> bool {
self.catalog.read().await.contains(name)
}
pub async fn lookup<R: ResponseHandler>(
&self,
request: &Request,
response_edns: Option<Edns>,
response_handle: R,
) -> ResponseInfo {
self.catalog
.read()
.await
.lookup(request, response_edns, response_handle)
.await
}
pub async fn read_catalog(&self) -> RwLockReadGuard<'_, Catalog> {
self.catalog.read().await
}
pub async fn write_catalog(&self) -> RwLockWriteGuard<'_, Catalog> {
self.catalog.write().await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::instance::dns_server::config::{
GeneralConfigBuilder, RecordBuilder, RecordType, RunConfigBuilder,
};
use anyhow::Result;
use hickory_client::client::{Client, ClientHandle};
use hickory_proto::rr;
use hickory_proto::runtime::TokioRuntimeProvider;
use hickory_proto::udp::UdpClientStream;
use maplit::hashmap;
use std::time::Duration;
#[tokio::test]
async fn it_works() -> Result<()> {
let mut server = Server::new(
RunConfigBuilder::default()
.general(GeneralConfigBuilder::default().build()?)
.build()?,
);
server.run().await?;
server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn can_resolve_records() -> Result<()> {
let configured_record = RecordBuilder::default()
.rr_type(RecordType::A)
.name("www.et.internal.".to_string())
.value("123.123.123.123".to_string())
.ttl(Duration::from_secs(60))
.build()?;
let configured_record2 = RecordBuilder::default()
.rr_type(RecordType::A)
.name("中文.et.internal.".to_string())
.value("123.123.123.123".to_string())
.ttl(Duration::from_secs(60))
.build()?;
let soa_record = RecordBuilder::default()
.rr_type(RecordType::SOA)
.name("et.internal.".to_string())
.value(
"ns.et.internal. hostmaster.et.internal. 2023101001 7200 3600 1209600 86400"
.to_string(),
)
.ttl(Duration::from_secs(60))
.build()?;
let config = RunConfigBuilder::default()
.general(
GeneralConfigBuilder::default()
.listen_udp("127.0.0.1:0")
.build()?,
)
.zones(hashmap! {
"et.internal.".to_string() => vec![configured_record.clone(), soa_record.clone(), configured_record2.clone()],
})
.build()?;
let mut server = Server::new(config);
server.run().await?;
let local_addr = server.udp_local_addr().unwrap();
let stream = UdpClientStream::builder(local_addr, TokioRuntimeProvider::default()).build();
let (mut client, background) = Client::connect(stream).await?;
let background_task = tokio::spawn(background);
let response = client
.query(
rr::Name::from_str("www.et.internal")?,
rr::DNSClass::IN,
rr::RecordType::A,
)
.await?;
drop(background_task);
println!("Response: {:?}", response);
assert_eq!(response.answers().len(), 1);
let expected_record: rr::Record = configured_record.try_into()?;
assert_eq!(response.answers().first().unwrap(), &expected_record);
server.shutdown().await?;
Ok(())
}
}

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