Compare commits

..
Author SHA1 Message Date
copilot-swe-agent[bot]andGitHub 003fdefc63 fix: address review comments - CIDR validation, dhcp_cidr implies DHCP, fmt fixes 2026-06-11 16:54:40 +00:00
copilot-swe-agent[bot]andGitHub a25c411249 test: add test verifying DHCP allocates IP within specified CIDR subnet 2026-06-08 15:54:22 +00:00
copilot-swe-agent[bot]andGitHub 0c11cefc04 fix: return error instead of silently ignoring invalid dhcp_cidr in launcher 2026-06-08 15:35:30 +00:00
copilot-swe-agent[bot]andGitHub 8181830902 feat: add DHCP subnet (CIDR) configuration parameter
Allow -d to optionally accept a CIDR subnet (e.g. -d 10.0.0.0/24)
to fix the DHCP address range. When specified, the DHCP allocator
uses the configured subnet instead of deriving it from peer IPs.

Changes:
- Add dhcp_cidr field to config struct and ConfigLoader trait
- Modify -d CLI arg to accept optional CIDR string value
- Update DHCP logic to use configured CIDR as default subnet
- Add dhcp_cidr to NetworkConfig proto message
- Update launcher to handle dhcp_cidr in gen_config/new_from_config
- Update i18n translations
2026-06-08 15:31:53 +00:00
copilot-swe-agent[bot]andGitHub 0a8c95879b Initial plan 2026-06-08 15:22:59 +00:00
韩嘉乐andGitHub c0f42ebe8c fix: improve log manager (#2329) 2026-06-07 23:32:22 +08:00
KKRainbowandGitHub 9d965cae64 Add FFI JNI JSON RPC bridge (#2326)
* Add FFI JNI JSON RPC bridge
* Add FFI instance list API
2026-06-07 17:48:58 +08:00
韩嘉乐andGitHub da28c8badc [OHOS] fix: 修复内存泄露问题,并重构日志管理,预防性修复数据库初始化异常问题 (#2328)
* fix: leak memory
feat: new log manager

* fix: fail to init db

* fix: fail to init db

* fix: cargo format
2026-06-07 16:18:54 +08:00
KKRainbowandGitHub e38b1354b3 Fix credential ospf logic, fix udp subnet proxy loop protection (#2315) 2026-06-07 12:40:09 +08:00
KKRainbowandGitHub 793b57c2a1 feat(ffi): add async data plane API (#2321)
* feat(ffi): add async data plane API
* feat(ffi): add async data plane examples
* test(ffi): make async Go dataplane tests self-contained
* docs(ffi): document Go async dataplane API
* docs(android): document dataplane JNI API
2026-06-06 21:52:50 +08:00
深鸣andGitHub 4a25ca934b build: update pnpm config for v11 compatibility (#2322)
pnpm v11 introduces breaking changes that cause frozen installations
to fail:

1. The "pnpm" field in package.json is no longer read. Moved
   `overrides` to `pnpm-workspace.yaml` to fix
   `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH`.
2. `strictDepBuilds` is now enabled by default. Added required
   dependencies (esbuild, unrs-resolver, vue-demi) to `allowBuilds` in
   the workspace config to fix `ERR_PNPM_IGNORED_BUILDS`.
2026-06-06 21:49:05 +08:00
KKRainbowandGitHub 13f2ebfe12 feat(ffi): add config server client bindings (#2320)
Add config server client support for the C FFI and Android JNI bindings.

Reuse the existing easytier::web_client::run_web_client path and 
NetworkInstanceManager; OHOS is unchanged.

Report successful remote config apply/delete operations through a 
callback, with one JSON event per affected instance.

Keep the config server client and FFI data plane mutually exclusive: once 
either side is in use, the other side returns an error instead of sharing
lifecycle state.
2026-06-06 01:52:27 +08:00
Luna YaoandGitHub 9ba364ff60 feat: string deserialization for prost enums (#2316)
Use pbjson to support string deserialization for enum fields

This allows TOML configs like:
    chainType = "Inbound"
instead of:
    chainType = 1

- Maintain backward compatibility with integer values
- Default serialization format is now string
2026-06-06 00:21:22 +08:00
NeilandGitHub ba653da9a0 fix: detect credential mode in TOML config loader (#2301)
TomlConfigLoader::new_from_str() always calls NetworkIdentity::new()
with unwrap_or_default() on network_secret, converting None to ''.
This creates a non-zero SHA256 digest, causing credential nodes loaded
from TOML to be misidentified as regular nodes (with network_secret),
which breaks Noise handshake authentication.

Fix: check if secure_mode is enabled AND network_secret is absent/empty,
and call NetworkIdentity::new_credential() in that case.

The same detection already exists in:
- core.rs (CLI path, via --credential flag)
- launcher.rs (GUI/web path, via gen_config)

This makes TOML config loading consistent with the other two entry points.
2026-06-04 22:38:40 +08:00
Luna YaoandGitHub 64c4d73044 fix QuicSocket payload offset (#2306) 2026-06-04 18:04:39 +08:00
w568wandGitHub e0745f4bab feat: Add data plane support to FFI (#2287)
1. Overview

This PR adds data plane APIs to easytier-ffi:

TCP Outbound:

- data_plane_tcp_connect
- data_plane_tcp_read
- data_plane_tcp_write
- data_plane_tcp_close

TCP Listener:

- data_plane_tcp_bind
- data_plane_tcp_accept
- data_plane_tcp_listener_close

UDP:

- data_plane_udp_bind
- data_plane_udp_send_to
- data_plane_udp_recv_from
- data_plane_udp_close

2. Key Changes

The main changes are focused on:

- easytier-contrib/easytier-ffi/src/lib.rs: Added FFI interfaces;
  made ERROR_MSG thread-safe.

- easytier/src/gateway/socks5.rs: Bridges the data plane to the
  existing Socks5 server logic.
  - Added EasyTierUdpSocket, mainly wrapping ref-counting and
    critical object (e.g., Socks5EntrySet) hold & drop logic,
    and exposing common fields (e.g., local_addr).
  - Extended Socks5Server functionality to expose TCP and UDP
    socket creation interfaces for FFI calls.

- Other files: Mostly pass-through logic.

- Added a relatively large Go usage example.
2026-06-04 17:17:41 +08:00
e3ca7ffa54 feat(socket): add Linux SO_MARK (fwmark) support for underlay sockets (#2288)
Adds a Linux-only socket_mark u32 config flag (CLI: --socket-mark, env:
ET_SOCKET_MARK, TOML/proto: flags.socket_mark, 0 = disabled) that is
applied as SO_MARK to every outbound underlay socket EasyTier creates:
TCP, UDP, QUIC, WebSocket, WireGuard connectors and listeners, plus the
FakeTCP decoy socket. Lets the host policy-route or filter EasyTier
underlay traffic with 'ip rule fwmark ...' or iptables -m mark.

Plumbing mirrors the existing bind_device pattern:
- FlagsInConfig.socket_mark (proto) + default 0 in gen_default_flags
- bind() builder gets a socket_mark arg; setup_socket2_ext calls
  apply_socket_mark which is a no-op for mark=0 and on non-Linux
- TunnelConnector trait gets set_socket_mark(u32) default-no-op method
- IP-based connectors override; create_listener_by_url and the connector
  factory pass mark from global_ctx flags
- QUIC threads mark through QuicEndpointManager::{server,connect}
- WebSocket/FakeTCP/TCP default-bind bypass paths apply mark via
  socket2::SockRef::from(&tokio_socket)
- ForeignNetworkEntry propagates parent socket_mark into its derived ctx

Includes a Linux smoke test plus a CAP_NET_ADMIN-gated test that does a
getsockopt(SO_MARK) round-trip to confirm the kernel applied the value.

SO_MARK requires CAP_NET_ADMIN; ignored silently on non-Linux. FakeTCP's
TUN-written segments are not covered (kernel doesn't tag raw TUN
writes); operators relying on fwmark for FakeTCP must apply an iptables
rule on the FakeTCP TUN device separately.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-03 09:57:18 +08:00
韩嘉乐andGitHub df97f3a64d fix: make ohos snapshot sync and config id validation safer (#2283)
* feat: add the management of config_store_snapshot

* fix: make ohrs snapshot sync and config id validation safer
2026-06-02 22:40:48 +08:00
fanyangandGitHub 00957e5f9d feat: add Docker healthcheck (#2279) 2026-05-24 23:12:51 +08:00
fanyangandGitHub bfa3383aaa chore: update kcp-sys (#2277) 2026-05-24 23:11:16 +08:00
fanyangandGitHub 73bea01f40 fix: support env vars for easytier-web (#2280)
* fix: support env vars for easytier-web
* fix: hide sensitive web env values
2026-05-22 23:59:16 +08:00
韩嘉乐andGitHub 0378191783 feat: add the management of config_store_snapshot (#2271) 2026-05-22 01:54:43 +08:00
ParkGardenandGitHub d5fa6a608d fix: Magisk module incorrectly matches the lookup main rule in Android 15 (#2259)
Fix the issue where the Magisk module incorrectly matches the lookup main rule in Android 15's cellular network rules, causing data plane connectivity failure
2026-05-18 12:51:49 +08:00
Luna YaoandGitHub 811f151155 refactor: rpc build (#2244)
rewrite rpc build with quota crate
2026-05-15 14:01:56 +08: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
fanyangandGitHub 362aa7a9cd fix: allow omitted ACL config fields (#2206) 2026-05-04 00:47:24 +08:00
KKRainbowandGitHub 12a7b5a5c5 fix: scope peer center server data to instance (#2198)
Stop sharing PeerCenterServer state through a process-global map so local and foreign-network services cannot mix peer-center data when peer ids overlap.
2026-05-02 01:43:01 +08:00
fanyangandGitHub 4eba9b07b6 fix(web-client): keep retrying unreachable config server (#2140)
Defer config-server connector creation into the web client retry loop so
service startup does not fail when network or DNS is unavailable.
2026-05-02 00:09:48 +08:00
KKRainbowandGitHub 1b48029bdc fix: clean stale foreign network state (#2197)
- clear foreign-network traffic metric peer caches on peer removal and network cleanup
- release reserved foreign-network peer IDs on handshake/add-peer error paths
- avoid creating no-op foreign-network token buckets when limits are unlimited
- shrink relay/session maps after cleanup and remove unused peer-center global data entries
2026-05-01 23:30:51 +08:00
KKRainbowandGitHub 3542e944cb fix(quic): prune stopped endpoints from pool (#2195)
* remove wss port 0 compatibility code
* fix(quic): prune stopped endpoints from pool
2026-05-01 18:51:39 +08:00
KKRainbowandGitHub 852d1c9e14 feat(gui): add UPnP and public IPv6 advanced options (#2194)
Expose disable-upnp and ipv6_public_addr_auto in the shared web/GUI config editor
bump release metadata to 2.6.3.
2026-05-01 13:45:19 +08:00
KKRainbowandGitHub 4958394469 fix: protect self peer during credential refresh and allow need-p2p peers through public server (#2192)
* fix: protect self peer during credential refresh

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

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

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

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

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

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

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

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

Changes:

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

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

pr

* do not run build on draft PRs

pr

* enable fail-fast for build workflows
2026-04-08 08:43:03 +08:00
Luna YaoandGitHub 371b4b70a3 proto(utils): add TransientDigest trait (#2071) 2026-04-08 00:06:48 +08:00
Luna YaoandGitHub b2cc38ee63 chore(clippy): disallow some methods from itertools (#2075) 2026-04-07 16:27:33 +08:00
330 changed files with 44909 additions and 7940 deletions
+35 -54
View File
@@ -1,29 +1,40 @@
[target.x86_64-unknown-linux-musl]
linker = "rust-lld"
rustflags = ["-C", "linker-flavor=ld.lld"]
# region Native
[target.x86_64-unknown-linux-gnu]
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
[target.aarch64-unknown-linux-ohos]
ar = "/usr/local/ohos-sdk/linux/native/llvm/bin/llvm-ar"
linker = "/home/runner/sdk/native/llvm/aarch64-unknown-linux-ohos-clang.sh"
[target.'cfg(all(windows, target_env = "msvc"))']
rustflags = ["-C", "target-feature=+crt-static"]
[target.aarch64-unknown-linux-ohos.env]
PKG_CONFIG_PATH = "/usr/local/ohos-sdk/linux/native/sysroot/usr/lib/pkgconfig:/usr/local/ohos-sdk/linux/native/sysroot/usr/local/lib/pkgconfig"
PKG_CONFIG_LIBDIR = "/usr/local/ohos-sdk/linux/native/sysroot/usr/lib:/usr/local/ohos-sdk/linux/native/sysroot/usr/local/lib"
PKG_CONFIG_SYSROOT_DIR = "/usr/local/ohos-sdk/linux/native/sysroot"
SYSROOT = "/usr/local/ohos-sdk/linux/native/sysroot"
# region
# region CI
[target.x86_64-unknown-linux-musl]
rustflags = ["-C", "target-feature=+crt-static"]
[target.aarch64-unknown-linux-musl]
linker = "aarch64-unknown-linux-musl-gcc"
rustflags = ["-C", "target-feature=+crt-static"]
[target.riscv64gc-unknown-linux-musl]
linker = "riscv64-unknown-linux-musl-gcc"
rustflags = ["-C", "target-feature=+crt-static"]
[target.'cfg(all(windows, target_env = "msvc"))']
[target.armv7-unknown-linux-musleabihf]
rustflags = ["-C", "target-feature=+crt-static"]
[target.armv7-unknown-linux-musleabi]
rustflags = ["-C", "target-feature=+crt-static"]
[target.arm-unknown-linux-musleabihf]
rustflags = ["-C", "target-feature=+crt-static"]
[target.arm-unknown-linux-musleabi]
rustflags = ["-C", "target-feature=+crt-static"]
[target.loongarch64-unknown-linux-musl]
rustflags = ["-C", "target-feature=+crt-static"]
[target.mipsel-unknown-linux-musl]
@@ -64,44 +75,14 @@ rustflags = [
"gcc",
]
[target.armv7-unknown-linux-musleabihf]
linker = "armv7-unknown-linux-musleabihf-gcc"
rustflags = ["-C", "target-feature=+crt-static"]
[target.aarch64-unknown-linux-ohos]
ar = "/usr/local/ohos-sdk/linux/native/llvm/bin/llvm-ar"
linker = "/home/runner/sdk/native/llvm/aarch64-unknown-linux-ohos-clang.sh"
[target.armv7-unknown-linux-musleabi]
linker = "armv7-unknown-linux-musleabi-gcc"
rustflags = ["-C", "target-feature=+crt-static"]
[target.aarch64-unknown-linux-ohos.env]
PKG_CONFIG_PATH = "/usr/local/ohos-sdk/linux/native/sysroot/usr/lib/pkgconfig:/usr/local/ohos-sdk/linux/native/sysroot/usr/local/lib/pkgconfig"
PKG_CONFIG_LIBDIR = "/usr/local/ohos-sdk/linux/native/sysroot/usr/lib:/usr/local/ohos-sdk/linux/native/sysroot/usr/local/lib"
PKG_CONFIG_SYSROOT_DIR = "/usr/local/ohos-sdk/linux/native/sysroot"
SYSROOT = "/usr/local/ohos-sdk/linux/native/sysroot"
[target.loongarch64-unknown-linux-musl]
linker = "loongarch64-unknown-linux-musl-gcc"
rustflags = ["-C", "target-feature=+crt-static"]
[target.arm-unknown-linux-musleabihf]
linker = "arm-unknown-linux-musleabihf-gcc"
rustflags = [
"-C",
"target-feature=+crt-static",
"-L",
"./musl_gcc/arm-unknown-linux-musleabihf/arm-unknown-linux-musleabihf/lib",
"-L",
"./musl_gcc/arm-unknown-linux-musleabihf/lib/gcc/arm-unknown-linux-musleabihf/15.1.0",
"-l",
"atomic",
"-l",
"gcc",
]
[target.arm-unknown-linux-musleabi]
linker = "arm-unknown-linux-musleabi-gcc"
rustflags = [
"-C",
"target-feature=+crt-static",
"-L",
"./musl_gcc/arm-unknown-linux-musleabi/arm-unknown-linux-musleabi/lib",
"-L",
"./musl_gcc/arm-unknown-linux-musleabi/lib/gcc/arm-unknown-linux-musleabi/15.1.0",
"-l",
"atomic",
"-l",
"gcc",
]
# endregion
+60 -13
View File
@@ -2,10 +2,17 @@ name: prepare-build
author: Luna
description: Prepare build environment
inputs:
web:
description: 'Whether to prepare the web build environment'
target:
description: 'The target to build for'
required: false
pnpm:
description: 'Whether to run pnpm build'
required: true
default: 'true'
pnpm-build-filter:
description: 'The filter argument for pnpm build (e.g. ./easytier-web/*)'
required: false
default: './easytier-web/*'
gui:
description: 'Whether to prepare the GUI build environment'
required: true
@@ -19,21 +26,61 @@ runs:
- run: mkdir -p easytier-gui/dist
shell: bash
- name: Setup Frontend Environment
if: ${{ inputs.web == 'true' }}
uses: ./.github/actions/prepare-pnpm
with:
build-filter: './easytier-web/*'
- name: Install GUI dependencies (Used by clippy)
if: ${{ inputs.gui == 'true' }}
- name: Install dependencies
if: ${{ runner.os == 'Linux' }}
run: |
bash ./.github/workflows/install_gui_dep.sh
sudo apt-get update
sudo apt-get install -qqy build-essential mold musl-tools
shell: bash
- name: Install Rust
- name: Setup Frontend Environment
if: ${{ inputs.pnpm == 'true' }}
uses: ./.github/actions/prepare-pnpm
with:
build-filter: ${{ inputs.pnpm-build-filter }}
- name: Install GUI dependencies (Linux)
if: ${{ inputs.gui == 'true' && runner.os == 'Linux' }}
run: |
bash ./.github/workflows/install_rust.sh
sudo apt-get install -qq xdg-utils \
libappindicator3-dev \
libgtk-3-dev \
librsvg2-dev \
libwebkit2gtk-4.1-dev \
libxdo-dev
shell: bash
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: 1.95
target: ${{ !contains(inputs.target, 'mips') && inputs.target || '' }}
components: ${{ contains(inputs.target, 'mips') && 'rust-src' || '' }}
cache: false
rustflags: ''
- name: Install Rust (MIPS)
if: ${{ contains(inputs.target, 'mips') }}
run: |
MUSL_TARGET=${{ inputs.target }}sf
mkdir -p ./musl_gcc
wget --inet4-only -c https://github.com/cross-tools/musl-cross/releases/download/20250520/${MUSL_TARGET}.tar.xz -P ./musl_gcc/
tar xf ./musl_gcc/${MUSL_TARGET}.tar.xz -C ./musl_gcc/
sudo ln -sf $(pwd)/musl_gcc/${MUSL_TARGET}/bin/*gcc /usr/bin/
sudo ln -sf $(pwd)/musl_gcc/${MUSL_TARGET}/include/ /usr/include/musl-cross
sudo ln -sf $(pwd)/musl_gcc/${MUSL_TARGET}/${MUSL_TARGET}/sysroot/ ./musl_gcc/sysroot
sudo chmod -R a+rwx ./musl_gcc
if [[ -d "./musl_gcc/sysroot" ]]; then
echo "BINDGEN_EXTRA_CLANG_ARGS=--sysroot=$(readlink -f ./musl_gcc/sysroot)" >> $GITHUB_ENV
fi
cd "$PWD/musl_gcc/${MUSL_TARGET}/lib/gcc/${MUSL_TARGET}/15.1.0" || exit 255
# for panic-abort
cp libgcc_eh.a libunwind.a
# for mimalloc
ar x libgcc.a _ctzsi2.o _clz.o _bswapsi2.o
ar rcs libctz.a _ctzsi2.o _clz.o _bswapsi2.o
shell: bash
- name: Setup protoc
+3
View File
@@ -42,4 +42,7 @@ EXPOSE 11011/tcp
# wss
EXPOSE 11012/tcp
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=5 \
CMD ["/usr/local/bin/easytier-cli", "--rpc-portal", "127.0.0.1:15888", "--output", "json", "node", "info"]
ENTRYPOINT ["/sbin/tini", "--", "easytier-core"]
+124 -147
View File
@@ -2,9 +2,14 @@ name: EasyTier Core
on:
push:
branches: ["develop", "main", "releases/**"]
branches: [ "develop", "main", "releases/**" ]
pull_request:
branches: ["develop", "main"]
branches: [ "develop", "main" ]
types: [ opened, synchronize, reopened, ready_for_review ]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
@@ -18,6 +23,7 @@ jobs:
pre_job:
# continue-on-error: true # Uncomment once integration is finished
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
# Map a step output to a job output
outputs:
# do not skip push on branch starts with releases/
@@ -30,7 +36,7 @@ jobs:
concurrent_skipping: 'same_content_newer'
skip_after_successful_duplicate: 'true'
cancel_others: 'true'
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", ".github/workflows/core.yml", ".github/workflows/install_rust.sh", "easytier-web/**"]'
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", ".github/workflows/core.yml", ".github/actions/**", "easytier-web/**"]'
build_web:
runs-on: ubuntu-latest
needs: pre_job
@@ -51,41 +57,48 @@ jobs:
easytier-web/frontend/dist/*
build:
strategy:
fail-fast: false
fail-fast: true
matrix:
include:
- TARGET: aarch64-unknown-linux-musl
OS: ubuntu-22.04
ARTIFACT_NAME: linux-aarch64
- TARGET: x86_64-unknown-linux-musl
OS: ubuntu-22.04
OS: ubuntu-24.04
ARTIFACT_NAME: linux-x86_64
- TARGET: riscv64gc-unknown-linux-musl
OS: ubuntu-22.04
ARTIFACT_NAME: linux-riscv64
- TARGET: mips-unknown-linux-musl
OS: ubuntu-22.04
ARTIFACT_NAME: linux-mips
- TARGET: mipsel-unknown-linux-musl
OS: ubuntu-22.04
ARTIFACT_NAME: linux-mipsel
- TARGET: armv7-unknown-linux-musleabihf # raspberry pi 2-3-4, not tested
OS: ubuntu-22.04
ARTIFACT_NAME: linux-armv7hf
- TARGET: armv7-unknown-linux-musleabi # raspberry pi 2-3-4, not tested
OS: ubuntu-22.04
ARTIFACT_NAME: linux-armv7
- TARGET: arm-unknown-linux-musleabihf # raspberry pi 0-1, not tested
OS: ubuntu-22.04
ARTIFACT_NAME: linux-armhf
- TARGET: arm-unknown-linux-musleabi # raspberry pi 0-1, not tested
OS: ubuntu-22.04
ARTIFACT_NAME: linux-arm
- TARGET: aarch64-unknown-linux-musl
OS: ubuntu-24.04-arm
ARTIFACT_NAME: linux-aarch64
- TARGET: riscv64gc-unknown-linux-musl
OS: ubuntu-24.04
ARTIFACT_NAME: linux-riscv64
- TARGET: loongarch64-unknown-linux-musl
OS: ubuntu-24.04
ARTIFACT_NAME: linux-loongarch64
- TARGET: armv7-unknown-linux-musleabihf # raspberry pi 2-3-4, not tested
OS: ubuntu-24.04
ARTIFACT_NAME: linux-armv7hf
- TARGET: armv7-unknown-linux-musleabi # raspberry pi 2-3-4, not tested
OS: ubuntu-24.04
ARTIFACT_NAME: linux-armv7
- TARGET: arm-unknown-linux-musleabihf # raspberry pi 0-1, not tested
OS: ubuntu-24.04
ARTIFACT_NAME: linux-armhf
- TARGET: arm-unknown-linux-musleabi # raspberry pi 0-1, not tested
OS: ubuntu-24.04
ARTIFACT_NAME: linux-arm
- TARGET: mips-unknown-linux-musl
OS: ubuntu-24.04
ARTIFACT_NAME: linux-mips
- TARGET: mipsel-unknown-linux-musl
OS: ubuntu-24.04
ARTIFACT_NAME: linux-mipsel
- TARGET: x86_64-unknown-freebsd
OS: ubuntu-24.04
ARTIFACT_NAME: freebsd-13.2-x86_64
BSD_VERSION: 13.2
- TARGET: x86_64-apple-darwin
OS: macos-latest
ARTIFACT_NAME: macos-x86_64
@@ -96,17 +109,12 @@ jobs:
- TARGET: x86_64-pc-windows-msvc
OS: windows-latest
ARTIFACT_NAME: windows-x86_64
- TARGET: aarch64-pc-windows-msvc
OS: windows-latest
ARTIFACT_NAME: windows-arm64
- TARGET: i686-pc-windows-msvc
OS: windows-latest
ARTIFACT_NAME: windows-i686
- TARGET: x86_64-unknown-freebsd
OS: ubuntu-22.04
ARTIFACT_NAME: freebsd-13.2-x86_64
BSD_VERSION: 13.2
- TARGET: aarch64-pc-windows-msvc
OS: windows-11-arm
ARTIFACT_NAME: windows-arm64
runs-on: ${{ matrix.OS }}
env:
@@ -131,8 +139,15 @@ jobs:
name: easytier-web-dashboard
path: easytier-web/frontend/dist/
- name: Prepare build environment
uses: ./.github/actions/prepare-build
with:
target: ${{ matrix.TARGET }}
gui: true
pnpm: true
token: ${{ secrets.GITHUB_TOKEN }}
- uses: Swatinem/rust-cache@v2
if: ${{ ! endsWith(matrix.TARGET, 'freebsd') }}
with:
# The prefix cache key, this can be changed to start a new cache manually.
# default: "v0-rust"
@@ -140,96 +155,54 @@ jobs:
shared-key: "core-registry"
cache-targets: "false"
- name: Setup protoc
uses: arduino/setup-protoc@v3
- uses: mlugg/setup-zig@v2
if: ${{ contains(matrix.OS, 'ubuntu') }}
with:
# GitHub repo token to use to avoid rate limiter
repo-token: ${{ secrets.GITHUB_TOKEN }}
version: 0.16.0
use-cache: true
- name: Build Core & Cli
if: ${{ ! endsWith(matrix.TARGET, 'freebsd') }}
run: |
bash ./.github/workflows/install_rust.sh
- uses: taiki-e/install-action@v2
if: ${{ contains(matrix.OS, 'ubuntu') }}
with:
tool: cargo-zigbuild
# loongarch need llvm-18
if [[ $TARGET =~ ^loongarch.*$ ]]; then
sudo apt-get install -qq llvm-18 clang-18
export LLVM_CONFIG_PATH=/usr/lib/llvm-18/bin/llvm-config
fi
# we set the sysroot when sysroot is a dir
# this dir is a soft link generated by install_rust.sh
# kcp-sys need this to gen ffi bindings. without this clang may fail to find some libc headers such as bits/libc-header-start.h
if [[ -d "./musl_gcc/sysroot" ]]; then
export BINDGEN_EXTRA_CLANG_ARGS=--sysroot=$(readlink -f ./musl_gcc/sysroot)
fi
if [[ $OS =~ ^ubuntu.*$ && $TARGET =~ ^mips.*$ ]]; then
cargo +nightly-2026-02-02 build -r --target $TARGET -Z build-std=std,panic_abort --package=easytier --features=jemalloc
- name: Build
if: ${{ !contains(matrix.TARGET, 'mips') }}
run: |
if [[ "$TARGET" == *windows* ]]; then
SUFFIX=.exe
else
if [[ $OS =~ ^windows.*$ ]]; then
SUFFIX=.exe
CORE_FEATURES="--features=mimalloc"
elif [[ $TARGET =~ ^riscv64.*$ || $TARGET =~ ^loongarch64.*$ || $TARGET =~ ^aarch64.*$ ]]; then
CORE_FEATURES="--features=mimalloc"
else
CORE_FEATURES="--features=jemalloc"
fi
cargo build --release --target $TARGET --package=easytier-web --features=embed
mv ./target/$TARGET/release/easytier-web"$SUFFIX" ./target/$TARGET/release/easytier-web-embed"$SUFFIX"
cargo build --release --target $TARGET $CORE_FEATURES
SUFFIX=""
fi
# Copied and slightly modified from @lmq8267 (https://github.com/lmq8267)
- name: Build Core & Cli (X86_64 FreeBSD)
uses: vmactions/freebsd-vm@670398e4236735b8b65805c3da44b7a511fb8b27
if: ${{ endsWith(matrix.TARGET, 'freebsd') }}
if [[ "$TARGET" =~ (x86_64-unknown-linux-musl|aarch64-unknown-linux-musl|windows|darwin) ]]; then
BUILD=build
else
BUILD=zigbuild
fi
if [[ "$TARGET" =~ ^(riscv64|loongarch64|aarch64).*$ || "$TARGET" =~ (freebsd|windows) ]]; then
FEATURES="mimalloc"
else
FEATURES="jemalloc"
fi
cargo $BUILD --release --target $TARGET --package=easytier-web --features=embed
mv ./target/$TARGET/release/easytier-web"$SUFFIX" ./target/$TARGET/release/easytier-web-embed"$SUFFIX"
cargo $BUILD --release --target $TARGET --features=$FEATURES
- name: Build (MIPS)
if: ${{ contains(matrix.TARGET, 'mips') }}
env:
TARGET: ${{ matrix.TARGET }}
with:
envs: TARGET
release: ${{ matrix.BSD_VERSION }}
arch: x86_64
usesh: true
mem: 6144
cpu: 4
run: |
uname -a
echo $SHELL
pwd
ls -lah
whoami
env | sort
pkg install -y git protobuf llvm-devel sudo curl
curl --proto 'https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
. $HOME/.cargo/env
rustup set auto-self-update disable
rustup install 1.93
rustup default 1.93
export CC=clang
export CXX=clang++
export CARGO_TERM_COLOR=always
cargo build --release --verbose --target $TARGET --package=easytier-web --features=embed
mv ./target/$TARGET/release/easytier-web ./target/$TARGET/release/easytier-web-embed
cargo build --release --verbose --target $TARGET --features=mimalloc
mkdir -p built-bins/$TARGET/release/
mv ./target/$TARGET/release/easytier-web-embed ./built-bins/$TARGET/release/easytier-web-embed
mv ./target/$TARGET/release/easytier-web ./built-bins/$TARGET/release/easytier-web
mv ./target/$TARGET/release/easytier-core ./built-bins/$TARGET/release/easytier-core
mv ./target/$TARGET/release/easytier-cli ./built-bins/$TARGET/release/easytier-cli
# remove dirs to avoid copy many files back
rm -rf ./target ~/.cargo
mv ./built-bins ./target
RUSTC_BOOTSTRAP: 1
run: |
cargo build -r --target $TARGET -Z build-std=std,panic_abort --package=easytier --features=jemalloc
- name: Compress
run: |
mkdir -p ./artifacts/objects/
# windows is the only OS using a different convention for executable file name
if [[ $OS =~ ^windows.*$ ]]; then
SUFFIX=.exe
@@ -242,26 +215,37 @@ jobs:
find "easytier/third_party/${ARCH_DIR}" -maxdepth 1 -type f \( -name "*.dll" -o -name "*.sys" \) -exec cp {} ./artifacts/objects/ \;
fi
fi
if [[ $GITHUB_REF_TYPE =~ ^tag$ ]]; then
TAG=$GITHUB_REF_NAME
else
TAG=$GITHUB_SHA
fi
if [[ $OS =~ ^ubuntu.*$ && ! $TARGET =~ (loongarch|freebsd) ]]; then
HOST_ARCH=$(uname -m)
case $HOST_ARCH in
x86_64) UPX_ARCH="amd64" ;;
aarch64) UPX_ARCH="arm64" ;;
*) UPX_ARCH="amd64" ;;
esac
if [[ $OS =~ ^ubuntu.*$ && ! $TARGET =~ ^.*freebsd$ && ! $TARGET =~ ^loongarch.*$ && ! $TARGET =~ ^riscv64.*$ ]]; then
UPX_VERSION=4.2.4
curl -L https://github.com/upx/upx/releases/download/v${UPX_VERSION}/upx-${UPX_VERSION}-amd64_linux.tar.xz -s | tar xJvf -
cp upx-${UPX_VERSION}-amd64_linux/upx .
./upx --lzma --best ./target/$TARGET/release/easytier-core"$SUFFIX"
./upx --lzma --best ./target/$TARGET/release/easytier-cli"$SUFFIX"
UPX_PKG="upx-${UPX_VERSION}-${UPX_ARCH}_linux"
curl -L "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/${UPX_PKG}.tar.xz" -s | tar xJvf -
cp "${UPX_PKG}/upx" .
UPX_BIN=./upx
fi
mv ./target/$TARGET/release/easytier-core"$SUFFIX" ./artifacts/objects/
mv ./target/$TARGET/release/easytier-cli"$SUFFIX" ./artifacts/objects/
if [[ ! $TARGET =~ ^mips.*$ ]]; then
mv ./target/$TARGET/release/easytier-web"$SUFFIX" ./artifacts/objects/
mv ./target/$TARGET/release/easytier-web-embed"$SUFFIX" ./artifacts/objects/
fi
for BIN in ./target/$TARGET/release/easytier-{core,cli,web,web-embed}"$SUFFIX"; do
if [[ -f "$BIN" ]]; then
if [[ -n "$UPX_BIN" ]]; then
$UPX_BIN --lzma --best "$BIN" || true
fi
mv "$BIN" ./artifacts/objects/
fi
done
mv ./artifacts/objects/* ./artifacts/
rm -rf ./artifacts/objects/
@@ -273,25 +257,10 @@ jobs:
path: |
./artifacts/*
core-result:
if: needs.pre_job.outputs.should_skip != 'true' && always()
runs-on: ubuntu-latest
needs:
- pre_job
- build_web
- build
steps:
- name: Mark result as failed
if: needs.build.result != 'success'
run: exit 1
magisk_build:
needs:
- pre_job
- build_web
- build
if: needs.pre_job.outputs.should_skip != 'true' && always()
build_magisk:
runs-on: ubuntu-latest
needs: [ pre_job, build_web, build ]
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
steps:
- name: Checkout Code
uses: actions/checkout@v5 # 必须先检出代码才能获取模块配置
@@ -311,7 +280,6 @@ jobs:
cp ./downloaded-binaries/easytier-cli ./easytier-contrib/easytier-magisk/
cp ./downloaded-binaries/easytier-web ./easytier-contrib/easytier-magisk/
# 上传生成的模块
- name: Upload Magisk Module
uses: actions/upload-artifact@v5
@@ -322,3 +290,12 @@ jobs:
!./easytier-contrib/easytier-magisk/build.sh
!./easytier-contrib/easytier-magisk/magisk_update.json
if-no-files-found: error
core-result:
runs-on: ubuntu-latest
needs: [ pre_job, build_web, build, build_magisk ]
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
steps:
- name: Mark result as failed
if: contains(needs.*.result, 'failure')
run: exit 1
+1 -1
View File
@@ -11,7 +11,7 @@ on:
image_tag:
description: 'Tag for this image build'
type: string
default: 'v2.6.0'
default: 'v2.6.4'
required: true
mark_latest:
description: 'Mark this image as latest'
+39 -86
View File
@@ -5,7 +5,12 @@ on:
branches: ["develop", "main", "releases/**"]
pull_request:
branches: ["develop", "main"]
types: [opened, synchronize, reopened, ready_for_review]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
@@ -18,6 +23,7 @@ jobs:
pre_job:
# continue-on-error: true # Uncomment once integration is finished
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
# Map a step output to a job output
outputs:
should_skip: ${{ steps.skip_check.outputs.should_skip == 'true' && !startsWith(github.ref_name, 'releases/') }}
@@ -29,20 +35,20 @@ jobs:
concurrent_skipping: 'same_content_newer'
skip_after_successful_duplicate: 'true'
cancel_others: 'true'
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-gui/**", ".github/workflows/gui.yml", ".github/workflows/install_rust.sh", ".github/workflows/install_gui_dep.sh", "easytier-web/frontend-lib/**"]'
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-gui/**", ".github/workflows/gui.yml", ".github/actions/**", "easytier-web/frontend-lib/**"]'
build-gui:
strategy:
fail-fast: false
fail-fast: true
matrix:
include:
- TARGET: aarch64-unknown-linux-musl
OS: ubuntu-22.04
GUI_TARGET: aarch64-unknown-linux-gnu
ARTIFACT_NAME: linux-aarch64
- TARGET: x86_64-unknown-linux-musl
OS: ubuntu-22.04
OS: ubuntu-24.04
GUI_TARGET: x86_64-unknown-linux-gnu
ARTIFACT_NAME: linux-x86_64
- TARGET: aarch64-unknown-linux-musl
OS: ubuntu-24.04-arm
GUI_TARGET: aarch64-unknown-linux-gnu
ARTIFACT_NAME: linux-aarch64
- TARGET: x86_64-apple-darwin
OS: macos-latest
@@ -57,16 +63,14 @@ jobs:
OS: windows-latest
GUI_TARGET: x86_64-pc-windows-msvc
ARTIFACT_NAME: windows-x86_64
- TARGET: aarch64-pc-windows-msvc
OS: windows-latest
GUI_TARGET: aarch64-pc-windows-msvc
ARTIFACT_NAME: windows-arm64
- TARGET: i686-pc-windows-msvc
OS: windows-latest
GUI_TARGET: i686-pc-windows-msvc
ARTIFACT_NAME: windows-i686
- TARGET: aarch64-pc-windows-msvc
OS: windows-11-arm
GUI_TARGET: aarch64-pc-windows-msvc
ARTIFACT_NAME: windows-arm64
runs-on: ${{ matrix.OS }}
env:
@@ -80,75 +84,29 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: Install GUI dependencies (x86 only)
if: ${{ matrix.TARGET == 'x86_64-unknown-linux-musl' }}
run: bash ./.github/workflows/install_gui_dep.sh
- name: Install GUI cross compile (aarch64 only)
if: ${{ matrix.TARGET == 'aarch64-unknown-linux-musl' }}
run: |
# see https://tauri.app/v1/guides/building/linux/
echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy main restricted" | sudo tee /etc/apt/sources.list
echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy-updates main restricted" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy universe" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy-updates universe" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy multiverse" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy-updates multiverse" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy-backports main restricted universe multiverse" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=amd64] http://security.ubuntu.com/ubuntu/ jammy-security main restricted" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=amd64] http://security.ubuntu.com/ubuntu/ jammy-security universe" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=amd64] http://security.ubuntu.com/ubuntu/ jammy-security multiverse" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy main restricted" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-updates main restricted" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy universe" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-updates universe" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy multiverse" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-updates multiverse" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-backports main restricted universe multiverse" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-security main restricted" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-security universe" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-security multiverse" | sudo tee -a /etc/apt/sources.list
sudo dpkg --add-architecture arm64
sudo apt update
sudo apt install aptitude
sudo aptitude install -y libgstreamer1.0-0:arm64 gstreamer1.0-plugins-base:arm64 gstreamer1.0-plugins-good:arm64 \
libgstreamer-gl1.0-0:arm64 libgstreamer-plugins-base1.0-0:arm64 libgstreamer-plugins-good1.0-0:arm64 libwebkit2gtk-4.1-0:arm64 \
libwebkit2gtk-4.1-dev:arm64 libssl-dev:arm64 gcc-aarch64-linux-gnu libsoup-3.0-dev:arm64 libjavascriptcoregtk-4.1-dev:arm64
echo "PKG_CONFIG_SYSROOT_DIR=/usr/aarch64-linux-gnu/" >> "$GITHUB_ENV"
echo "PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig/" >> "$GITHUB_ENV"
- name: Install rpm package (Linux target only)
if: ${{ contains(matrix.TARGET, '-linux-') }}
run: |
sudo apt update
sudo apt install -y rpm
- name: Set current ref as env variable
run: |
echo "GIT_DESC=$(git log -1 --format=%cd.%h --date=format:%Y-%m-%d_%H:%M:%S)" >> $GITHUB_ENV
- name: Setup Frontend Environment
uses: ./.github/actions/prepare-pnpm
- name: Prepare build environment
uses: ./.github/actions/prepare-build
with:
target: ${{ matrix.TARGET }}
gui: true
pnpm: true
pnpm-build-filter: ''
token: ${{ secrets.GITHUB_TOKEN }}
- uses: Swatinem/rust-cache@v2
with:
# The prefix cache key, this can be changed to start a new cache manually.
# default: "v0-rust"
prefix-key: ""
- name: Install rust target
run: bash ./.github/workflows/install_rust.sh
- name: Setup protoc
uses: arduino/setup-protoc@v3
with:
# GitHub repo token to use to avoid rate limiter
repo-token: ${{ secrets.GITHUB_TOKEN }}
shared-key: "gui-registry"
cache-targets: "false"
- name: copy correct DLLs
if: ${{ matrix.OS == 'windows-latest' }}
if: ${{ contains(matrix.GUI_TARGET, 'windows') }}
run: |
case $TARGET in
x86_64*) ARCH_DIR=x86_64 ;;
@@ -164,10 +122,9 @@ jobs:
uses: tauri-apps/tauri-action@v0
with:
projectPath: ./easytier-gui
# https://tauri.app/v1/guides/building/linux/#cross-compiling-tauri-applications-for-arm-based-devices
args: --verbose --target ${{ matrix.GUI_TARGET }} ${{ contains(matrix.TARGET, '-linux-') && contains(matrix.TARGET, 'aarch64') && '--bundles deb,rpm' || '' }}
args: --verbose --target ${{ matrix.GUI_TARGET }}
- name: Compress
- name: Collect artifact
run: |
mkdir -p ./artifacts/objects/
@@ -176,18 +133,16 @@ jobs:
else
TAG=$GITHUB_SHA
fi
# copy gui bundle, gui is built without specific target
if [[ $OS =~ ^windows.*$ ]]; then
if [[ $GUI_TARGET =~ windows ]]; then
mv ./target/$GUI_TARGET/release/bundle/nsis/*.exe ./artifacts/objects/
elif [[ $OS =~ ^macos.*$ ]]; then
elif [[ $GUI_TARGET =~ darwin ]]; then
mv ./target/$GUI_TARGET/release/bundle/dmg/*.dmg ./artifacts/objects/
elif [[ $OS =~ ^ubuntu.*$ && ! $TARGET =~ ^mips.*$ ]]; then
elif [[ $GUI_TARGET =~ linux ]]; then
mv ./target/$GUI_TARGET/release/bundle/deb/*.deb ./artifacts/objects/
mv ./target/$GUI_TARGET/release/bundle/rpm/*.rpm ./artifacts/objects/
if [[ $GUI_TARGET =~ ^x86_64.*$ ]]; then
# currently only x86 appimage is supported
mv ./target/$GUI_TARGET/release/bundle/appimage/*.AppImage ./artifacts/objects/
fi
mv ./target/$GUI_TARGET/release/bundle/appimage/*.AppImage ./artifacts/objects/
fi
mv ./artifacts/objects/* ./artifacts/
@@ -201,12 +156,10 @@ jobs:
./artifacts/*
gui-result:
if: needs.pre_job.outputs.should_skip != 'true' && always()
runs-on: ubuntu-latest
needs:
- pre_job
- build-gui
needs: [ pre_job, build-gui ]
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
steps:
- name: Mark result as failed
if: needs.build-gui.result != 'success'
if: contains(needs.*.result, 'failure')
run: exit 1
-11
View File
@@ -1,11 +0,0 @@
sudo apt update
sudo apt install -qq libwebkit2gtk-4.1-dev \
build-essential \
curl \
wget \
file \
libgtk-3-dev \
librsvg2-dev \
libxdo-dev \
libssl-dev \
patchelf
-61
View File
@@ -1,61 +0,0 @@
#!/usr/bin/env bash
# env needed:
# - TARGET
# - GUI_TARGET
# - OS
# dependencies are only needed on ubuntu as that's the only place where
# we make cross-compilation
if [[ $OS =~ ^ubuntu.*$ ]]; then
sudo apt-get update && sudo apt-get install -qq musl-tools libappindicator3-dev llvm clang
# https://github.com/cross-tools/musl-cross/releases
# if "musl" is a substring of TARGET, we assume that we are using musl
MUSL_TARGET=$TARGET
# if target is mips or mipsel, we should use soft-float version of musl
if [[ $TARGET =~ ^mips.*$ || $TARGET =~ ^mipsel.*$ ]]; then
MUSL_TARGET=${TARGET}sf
elif [[ $TARGET =~ ^riscv64gc-.*$ ]]; then
MUSL_TARGET=${TARGET/#riscv64gc-/riscv64-}
fi
if [[ $MUSL_TARGET =~ musl ]]; then
mkdir -p ./musl_gcc
wget --inet4-only -c https://github.com/cross-tools/musl-cross/releases/download/20250520/${MUSL_TARGET}.tar.xz -P ./musl_gcc/
tar xf ./musl_gcc/${MUSL_TARGET}.tar.xz -C ./musl_gcc/
sudo ln -sf $(pwd)/musl_gcc/${MUSL_TARGET}/bin/*gcc /usr/bin/
sudo ln -sf $(pwd)/musl_gcc/${MUSL_TARGET}/include/ /usr/include/musl-cross
sudo ln -sf $(pwd)/musl_gcc/${MUSL_TARGET}/${MUSL_TARGET}/sysroot/ ./musl_gcc/sysroot
sudo chmod -R a+rwx ./musl_gcc
fi
fi
# see https://github.com/rust-lang/rustup/issues/3709
rustup set auto-self-update disable
rustup install 1.93
rustup default 1.93
# mips/mipsel cannot add target from rustup, need compile by ourselves
if [[ $OS =~ ^ubuntu.*$ && $TARGET =~ ^mips.*$ ]]; then
cd "$PWD/musl_gcc/${MUSL_TARGET}/lib/gcc/${MUSL_TARGET}/15.1.0" || exit 255
# for panic-abort
cp libgcc_eh.a libunwind.a
# for mimalloc
ar x libgcc.a _ctzsi2.o _clz.o _bswapsi2.o
ar rcs libctz.a _ctzsi2.o _clz.o _bswapsi2.o
rustup toolchain install nightly-2026-02-02-x86_64-unknown-linux-gnu
rustup component add rust-src --toolchain nightly-2026-02-02-x86_64-unknown-linux-gnu
# https://github.com/rust-lang/rust/issues/128808
# remove it after Cargo or rustc fix this.
RUST_LIB_SRC=$HOME/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/
if [[ -f $RUST_LIB_SRC/library/Cargo.lock && ! -f $RUST_LIB_SRC/Cargo.lock ]]; then
cp -f $RUST_LIB_SRC/library/Cargo.lock $RUST_LIB_SRC/Cargo.lock
fi
else
rustup target add $TARGET
if [[ $GUI_TARGET != '' ]]; then
rustup target add $GUI_TARGET
fi
fi
+41 -38
View File
@@ -5,7 +5,12 @@ on:
branches: ["develop", "main", "releases/**"]
pull_request:
branches: ["develop", "main"]
types: [opened, synchronize, reopened, ready_for_review]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
@@ -18,6 +23,7 @@ jobs:
pre_job:
# continue-on-error: true # Uncomment once integration is finished
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
# Map a step output to a job output
outputs:
should_skip: ${{ steps.skip_check.outputs.should_skip == 'true' && !startsWith(github.ref_name, 'releases/') }}
@@ -29,20 +35,25 @@ jobs:
concurrent_skipping: 'same_content_newer'
skip_after_successful_duplicate: 'true'
cancel_others: 'true'
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-gui/**", "tauri-plugin-vpnservice/**", ".github/workflows/mobile.yml", ".github/workflows/install_rust.sh"]'
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-gui/**", "tauri-plugin-vpnservice/**", ".github/workflows/mobile.yml", ".github/actions/**"]'
build-mobile:
strategy:
fail-fast: false
fail-fast: true
matrix:
include:
- TARGET: android
OS: ubuntu-22.04
ARTIFACT_NAME: android
runs-on: ${{ matrix.OS }}
- TARGET: aarch64-linux-android
ARCH: aarch64
- TARGET: armv7-linux-androideabi
ARCH: armv7
- TARGET: i686-linux-android
ARCH: i686
- TARGET: x86_64-linux-android
ARCH: x86_64
runs-on: ubuntu-latest
env:
NAME: easytier
TARGET: ${{ matrix.TARGET }}
OS: ${{ matrix.OS }}
ARCH: ${{ matrix.ARCH }}
OSS_BUCKET: ${{ secrets.ALIYUN_OSS_BUCKET }}
needs: pre_job
if: needs.pre_job.outputs.should_skip != 'true'
@@ -61,47 +72,41 @@ jobs:
- name: Setup Android SDK
uses: android-actions/setup-android@v3
with:
cmdline-tools-version: 11076708
packages: 'build-tools;34.0.0 ndk;26.0.10792818 tools platform-tools platforms;android-34 '
cmdline-tools-version: 12.0
packages: 'build-tools;34.0.0 ndk;26.0.10792818 platform-tools platforms;android-34 '
- name: Setup Android Environment
run: |
echo "$ANDROID_HOME/platform-tools" >> $GITHUB_PATH
echo "$ANDROID_HOME/ndk/26.0.10792818/toolchains/llvm/prebuilt/linux-x86_64/bin" >> $GITHUB_PATH
echo "NDK_HOME=$ANDROID_HOME/ndk/26.0.10792818/" > $GITHUB_ENV
echo "NDK_HOME=$ANDROID_HOME/ndk/26.0.10792818/" >> $GITHUB_ENV
- name: Setup Frontend Environment
uses: ./.github/actions/prepare-pnpm
- name: Prepare build environment
uses: ./.github/actions/prepare-build
with:
target: ${{ matrix.TARGET }}
gui: false
pnpm: true
pnpm-build-filter: ''
token: ${{ secrets.GITHUB_TOKEN }}
- uses: Swatinem/rust-cache@v2
with:
# The prefix cache key, this can be changed to start a new cache manually.
# default: "v0-rust"
prefix-key: ""
shared-key: "gui-registry"
cache-targets: "false"
- name: Install rust target
run: |
bash ./.github/workflows/install_rust.sh
rustup target add aarch64-linux-android
rustup target add armv7-linux-androideabi
rustup target add i686-linux-android
rustup target add x86_64-linux-android
- name: Setup protoc
uses: arduino/setup-protoc@v3
with:
# GitHub repo token to use to avoid rate limiter
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Build Android
- name: Build
run: |
cd easytier-gui
pnpm tauri android build
pnpm tauri android build --apk --target "$ARCH" --split-per-abi
- name: Compress
- name: Collect artifact
run: |
mkdir -p ./artifacts/objects/
mv easytier-gui/src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk ./artifacts/objects/
mv easytier-gui/src-tauri/gen/android/app/build/outputs/apk/*/release/*.apk ./artifacts/objects/
if [[ $GITHUB_REF_TYPE =~ ^tag$ ]]; then
TAG=$GITHUB_REF_NAME
@@ -109,23 +114,21 @@ jobs:
TAG=$GITHUB_SHA
fi
mv ./artifacts/objects/* ./artifacts
mv ./artifacts/objects/* ./artifacts/
rm -rf ./artifacts/objects/
- name: Archive artifact
uses: actions/upload-artifact@v5
with:
name: easytier-gui-${{ matrix.ARTIFACT_NAME }}
name: easytier-mobile-android-${{ matrix.ARCH }}
path: |
./artifacts/*
mobile-result:
if: needs.pre_job.outputs.should_skip != 'true' && always()
runs-on: ubuntu-latest
needs:
- pre_job
- build-mobile
needs: [ pre_job, build-mobile ]
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
steps:
- name: Mark result as failed
if: needs.build-mobile.result != 'success'
if: contains(needs.*.result, 'failure')
run: exit 1
+15 -1
View File
@@ -6,14 +6,22 @@ on:
paths:
- "**/*.nix"
- "flake.lock"
- "rust-toolchain.toml"
pull_request:
branches: ["main", "develop"]
types: [opened, synchronize, reopened, ready_for_review]
paths:
- "**/*.nix"
- "flake.lock"
- "rust-toolchain.toml"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
check-full-shell:
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
@@ -26,5 +34,11 @@ jobs:
- name: Magic Nix Cache
uses: DeterminateSystems/magic-nix-cache-action@v6
- name: Check full devShell
- name: Warm up full devShell
run: nix develop .#full --command true
- name: Cargo check in flake environment
run: nix develop .#full --command cargo check
- name: Cargo build in flake environment
run: nix develop .#full --command cargo build
+33 -12
View File
@@ -8,8 +8,13 @@ on:
- '!*-pre'
pull_request:
branches: ["develop", "main"]
types: [opened, synchronize, reopened, ready_for_review]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
@@ -20,18 +25,29 @@ defaults:
jobs:
cargo_fmt_check:
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: fmt check
- name: Prepare build environment
uses: ./.github/actions/prepare-build
with:
gui: false
pnpm: false
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
components: rustfmt
- name: Check formatting
working-directory: ./easytier-contrib/easytier-ohrs
run: |
bash ../../.github/workflows/install_rust.sh
rustup component add rustfmt
cargo fmt --all -- --check
run: cargo fmt --all -- --check
pre_job:
# continue-on-error: true # Uncomment once integration is finished
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
# Map a step output to a job output
outputs:
# do not skip push on branch starts with releases/
@@ -44,7 +60,8 @@ jobs:
concurrent_skipping: "same_content_newer"
skip_after_successful_duplicate: "true"
cancel_others: "true"
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-contrib/easytier-ohrs/**", ".github/workflows/ohos.yml", ".github/workflows/install_rust.sh"]'
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-contrib/easytier-ohrs/**", ".github/workflows/ohos.yml", ".github/actions/**"]'
build-ohos:
runs-on: ubuntu-latest
needs: pre_job
@@ -56,13 +73,12 @@ jobs:
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
sudo apt-get install -qq \
build-essential \
wget \
unzip \
git \
pkg-config curl libgl1-mesa-dev expect
sudo apt-get clean
- name: Resolve easytier version
run: |
@@ -134,6 +150,15 @@ jobs:
run: |
echo "TARGET_ARCH=aarch64-linux-ohos" >> $GITHUB_ENV
rustup install stable
rustup default stable
rustup target add aarch64-unknown-linux-ohos
- uses: taiki-e/install-action@v2
with:
tool: ohrs
- name: Create clang wrapper script
run: |
sudo mkdir -p $OHOS_NDK_HOME/native/llvm
@@ -152,11 +177,7 @@ jobs:
run: |
sudo apt-get install -y llvm clang lldb lld
sudo apt-get install -y protobuf-compiler
bash ../../.github/workflows/install_rust.sh
source env.sh
cargo install ohrs
rustup target add aarch64-unknown-linux-ohos
cargo update easytier
ohrs doctor
ohrs build --release --arch aarch
ohrs artifact
+2 -2
View File
@@ -18,7 +18,7 @@ on:
version:
description: 'Version for this release'
type: string
default: 'v2.6.0'
default: 'v2.6.4'
required: true
make_latest:
description: 'Mark this release as latest'
@@ -92,4 +92,4 @@ jobs:
files: |
./zipped_assets/*
token: ${{ secrets.GITHUB_TOKEN }}
tag_name: ${{ inputs.version }}
tag_name: ${{ inputs.version }}
+28 -19
View File
@@ -6,6 +6,10 @@ on:
pull_request:
branches: [ "develop", "main" ]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
# RUSTC_WRAPPER: "sccache"
@@ -30,7 +34,7 @@ jobs:
# All of these options are optional, so you can remove them if you are happy with the defaults
concurrent_skipping: 'never'
skip_after_successful_duplicate: 'true'
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", ".github/workflows/test.yml", ".github/workflows/install_gui_dep.sh", ".github/workflows/install_rust.sh"]'
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", ".github/workflows/test.yml", ".github/actions/**"]'
check:
name: Run linters & check
@@ -44,35 +48,36 @@ jobs:
uses: ./.github/actions/prepare-build
with:
gui: true
web: true
pnpm: true
token: ${{ secrets.GITHUB_TOKEN }}
- uses: Swatinem/rust-cache@v2
- name: Install rustfmt and clippy
run: |
rustup component add rustfmt
rustup component add clippy
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
components: rustfmt,clippy
rustflags: ''
- uses: taiki-e/install-action@cargo-hack
- name: Check Cargo.lock is up to date
run: |
if ! cargo metadata --format-version 1 --locked --no-deps > /dev/null; then
echo "::error::Cargo.lock is out of date. Run cargo generate-lockfile or cargo build locally, then commit Cargo.lock."
exit 1
fi
- name: Check formatting
if: ${{ !cancelled() }}
run: cargo fmt --all -- --check
- name: Check Clippy
if: ${{ !cancelled() }}
run: cargo clippy --all-targets --features full --all -- -D warnings
- name: Check features
if: ${{ !cancelled() }}
run: cargo hack check --package easytier --each-feature --exclude-features macos-ne --verbose
- name: Check Cargo.lock is up to date
if: ${{ !cancelled() }}
run: |
if ! cargo metadata --format-version 1 --locked > /dev/null; then
echo "::error::Cargo.lock is out of date. Run cargo generate-lockfile or cargo build locally, then commit Cargo.lock."
exit 1
fi
pre-test:
name: Build test
runs-on: ubuntu-latest
@@ -85,7 +90,7 @@ jobs:
uses: ./.github/actions/prepare-build
with:
gui: true
web: true
pnpm: true
token: ${{ secrets.GITHUB_TOKEN }}
- uses: Swatinem/rust-cache@v2
@@ -123,6 +128,10 @@ jobs:
- name: Setup tools for test
run: sudo apt install bridge-utils
- name: Setup upnpd for test
run: |
sudo apt-get update
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y miniupnpd miniupnpd-iptables iptables
- name: Setup system for test
run: |
@@ -146,9 +155,9 @@ jobs:
test:
runs-on: ubuntu-latest
needs: [ pre_job, test_matrix ]
if: needs.pre_job.outputs.should_skip != 'true' && always()
needs: [ pre_job, check, test_matrix ]
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
steps:
- name: Mark result as failed
if: needs.test_matrix.result != 'success'
if: contains(needs.*.result, 'failure')
run: exit 1
+3
View File
@@ -43,3 +43,6 @@ easytier-gui/src-tauri/*.sys
.direnv
.flake-profile
# contrib
go.sum
+3 -3
View File
@@ -26,7 +26,7 @@ Thank you for your interest in contributing to EasyTier! This document provides
#### Required Tools
- Node.js v21 or higher
- pnpm v9 or higher
- Rust toolchain (version 1.93)
- Rust toolchain (version 1.95)
- LLVM and Clang
- Protoc (Protocol Buffers compiler)
@@ -79,8 +79,8 @@ sudo apt install -y bridge-utils
2. Install dependencies:
```bash
# Install Rust toolchain
rustup install 1.93
rustup default 1.93
rustup install 1.95
rustup default 1.95
# Install project dependencies
pnpm -r install
+3 -3
View File
@@ -34,7 +34,7 @@
#### 必需工具
- Node.js v21 或更高版本
- pnpm v9 或更高版本
- Rust 工具链(版本 1.93
- Rust 工具链(版本 1.95
- LLVM 和 Clang
- ProtocProtocol Buffers 编译器)
@@ -87,8 +87,8 @@ sudo apt install -y bridge-utils
2. 安装依赖:
```bash
# 安装 Rust 工具链
rustup install 1.93
rustup default 1.93
rustup install 1.95
rustup default 1.95
# 安装项目依赖
pnpm -r install
Generated
+1776 -1182
View File
File diff suppressed because it is too large Load Diff
+4 -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",
@@ -14,6 +13,10 @@ exclude = [
"easytier-contrib/easytier-ohrs", # it needs ohrs sdk
]
[workspace.package]
edition = "2024"
rust-version = "1.95"
[profile.dev]
panic = "unwind"
debug = 2
+3 -3
View File
@@ -108,9 +108,9 @@ After successful execution, you can check the network status using `easytier-cli
```text
| ipv4 | hostname | cost | lat_ms | loss_rate | rx_bytes | tx_bytes | tunnel_proto | nat_type | id | version |
| ------------ | -------------- | ----- | ------ | --------- | -------- | -------- | ------------ | -------- | ---------- | --------------- |
| 10.126.126.1 | abc-1 | Local | * | * | * | * | udp | FullCone | 439804259 | 2.6.0-70e69a38~ |
| 10.126.126.2 | abc-2 | p2p | 3.452 | 0 | 17.33 kB | 20.42 kB | udp | FullCone | 390879727 | 2.6.0-70e69a38~ |
| | PublicServer_a | p2p | 27.796 | 0.000 | 50.01 kB | 67.46 kB | tcp | Unknown | 3771642457 | 2.6.0-70e69a38~ |
| 10.126.126.1 | abc-1 | Local | * | * | * | * | udp | FullCone | 439804259 | 2.6.2-70e69a38~ |
| 10.126.126.2 | abc-2 | p2p | 3.452 | 0 | 17.33 kB | 20.42 kB | udp | FullCone | 390879727 | 2.6.2-70e69a38~ |
| | PublicServer_a | p2p | 27.796 | 0.000 | 50.01 kB | 67.46 kB | tcp | Unknown | 3771642457 | 2.6.2-70e69a38~ |
```
You can test connectivity between nodes:
+3 -3
View File
@@ -108,9 +108,9 @@ sudo easytier-core -d --network-name abc --network-secret abc -p tcp://<共享
```text
| ipv4 | hostname | cost | lat_ms | loss_rate | rx_bytes | tx_bytes | tunnel_proto | nat_type | id | version |
| ------------ | -------------- | ----- | ------ | --------- | -------- | -------- | ------------ | -------- | ---------- | --------------- |
| 10.126.126.1 | abc-1 | Local | * | * | * | * | udp | FullCone | 439804259 | 2.6.0-70e69a38~ |
| 10.126.126.2 | abc-2 | p2p | 3.452 | 0 | 17.33 kB | 20.42 kB | udp | FullCone | 390879727 | 2.6.0-70e69a38~ |
| | PublicServer_a | p2p | 27.796 | 0.000 | 50.01 kB | 67.46 kB | tcp | Unknown | 3771642457 | 2.6.0-70e69a38~ |
| 10.126.126.1 | abc-1 | Local | * | * | * | * | udp | FullCone | 439804259 | 2.6.2-70e69a38~ |
| 10.126.126.2 | abc-2 | p2p | 3.452 | 0 | 17.33 kB | 20.42 kB | udp | FullCone | 390879727 | 2.6.2-70e69a38~ |
| | PublicServer_a | p2p | 27.796 | 0.000 | 50.01 kB | 67.46 kB | tcp | Unknown | 3771642457 | 2.6.2-70e69a38~ |
```
您可以测试节点之间的连通性:
@@ -1,7 +1,7 @@
[package]
name = "easytier-android-jni"
version = "0.1.0"
edition = "2021"
edition.workspace = true
[lib]
crate-type = ["cdylib"]
@@ -13,4 +13,5 @@ log = "0.4"
android_logger = "0.13"
serde = { version = "1.0.220", features = ["derive"] }
serde_json = "1.0"
easytier = { path = "../../easytier" }
easytier = { path = "../../easytier" }
easytier-ffi = { path = "../easytier-ffi", default-features = false, features = ["ffi-dataplane"] }
@@ -8,6 +8,7 @@
- 📱 原生 Android JNI 支持
- 🔧 支持多种 Android 架构 (arm64-v8a, armeabi-v7a, x86, x86_64)
- 🛡️ 类型安全的 Java 接口
- 🔌 支持通过 JSON 调用已暴露的 EasyTier RPC 查询/管理接口
- 📝 详细的错误处理和日志记录
## 支持的架构
@@ -176,6 +177,20 @@ public class EasyTierManager {
}
```
### 通用 JSON RPC
`EasyTierJNI.callJsonRpc(serviceName, methodName, domainName, payloadJson)` 可以调用已暴露的
EasyTier RPC 服务,payload 和返回值均为 protobuf JSON。该接口不支持
`api.manage.WebClientService`;实例启动、保留、删除、信息收集仍使用专用 JNI API。
```java
String response = EasyTierJNI.callJsonRpc(
"api.logger.LoggerRpcService",
"get_logger_config",
"{}"
);
```
### VPN 服务集成
如果您要在 Android VPN 服务中使用:
@@ -264,4 +279,4 @@ public class EasyTierVpnService extends VpnService {
- [EasyTier 主项目](https://github.com/EasyTier/EasyTier)
- [Android NDK 文档](https://developer.android.com/ndk)
- [Rust JNI 文档](https://docs.rs/jni/)
- [Rust JNI 文档](https://docs.rs/jni/)
@@ -0,0 +1,17 @@
use std::{env, path::PathBuf};
fn main() {
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if !matches!(target_os.as_str(), "android" | "linux") {
return;
}
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
let exports = manifest_dir.join("exports.map");
println!("cargo:rerun-if-changed={}", exports.display());
println!(
"cargo:rustc-cdylib-link-arg=-Wl,--version-script={}",
exports.display()
);
println!("cargo:rustc-cdylib-link-arg=-Wl,--exclude-libs,ALL");
}
@@ -0,0 +1,7 @@
{
global:
Java_com_easytier_jni_EasyTierJNI_*;
Java_com_easytier_jni_EasyTierDataPlaneJNI_*;
local:
*;
};
@@ -0,0 +1,451 @@
package com.easytier.jni
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
/**
* EasyTier data-plane API for Android.
*
* Dataplane APIs do not create or start an EasyTier instance by themselves.
* Start an instance with [EasyTierJNI.runNetworkInstance] first, then pass the
* same `instanceName` to [EasyTierDataPlane.tcpConnect],
* [EasyTierDataPlane.tcpBind], or [EasyTierDataPlane.udpBind]. If that instance
* is not running, the native start call fails and the coroutine wrapper throws
* the last EasyTier FFI error.
*
* Typical setup:
* ```
* val instanceName = "android-dataplane-demo"
* val config = """
* instance_name = "$instanceName"
* ipv4 = "10.144.0.1"
* listeners = ["tcp://0.0.0.0:11010"]
*
* [network_identity]
* network_name = "android-dataplane-demo"
* network_secret = "replace-with-a-real-secret"
*
* [[peer]]
* uri = "tcp://peer.example.com:11010"
*
* [flags]
* no_tun = true
* bind_device = false
* """.trimIndent()
*
* EasyTierJNI.runNetworkInstance(config)
* ```
*
* After the instance is running, most callers should use [EasyTierDataPlane]
* and the socket/stream classes below. [EasyTierDataPlaneJNI] is the low-level
* native op-handle ABI used by the coroutine wrappers.
*
* TCP client usage:
* ```
* val stream = EasyTierDataPlane.tcpConnect(instanceName, "10.144.0.2", 8080, 5_000)
* try {
* stream.write("ping".toByteArray(), 5_000)
* val reply = stream.read(4096, 5_000)
* } finally {
* stream.close()
* }
* ```
*
* TCP server usage:
* ```
* val listener = EasyTierDataPlane.tcpBind(instanceName, 8080, 5_000)
* try {
* val stream = listener.accept(30_000)
* try {
* stream.write(stream.read(4096, 5_000), 5_000)
* } finally {
* stream.close()
* }
* } finally {
* listener.close()
* }
* ```
*
* UDP usage:
* ```
* val socket = EasyTierDataPlane.udpBind(instanceName, 0, 5_000)
* try {
* socket.sendTo("10.144.0.2", 9000, "ping".toByteArray(), 5_000)
* val packet = socket.recvFrom(4096, 5_000)
* } finally {
* socket.close()
* }
* ```
*
* Operation model:
* - Each suspend function starts one native async op, waits on Dispatchers.IO,
* then consumes the op with the matching finish call.
* - Coroutine cancellation cancels and frees the native op.
* - Returned stream/listener/socket handles must be closed by the caller.
* - Input ByteArray data is copied by the native start call; output data is
* copied into Kotlin ByteArray before the native buffer is freed.
*/
/** Data-plane IPv4/port pair returned by EasyTier FFI. */
data class DataPlaneSocketAddress(val ip: String, val port: Int)
/** Result of a completed TCP connect op. */
data class DataPlaneTcpConnectResult(val handle: Long, val localAddress: DataPlaneSocketAddress)
/** Result of a completed TCP bind op. */
data class DataPlaneTcpBindResult(val handle: Long, val localAddress: DataPlaneSocketAddress)
/** Result of a completed TCP accept op. */
data class DataPlaneTcpAcceptResult(
val handle: Long,
val localAddress: DataPlaneSocketAddress,
val peerAddress: DataPlaneSocketAddress
)
/** Result of a completed TCP read op. */
data class DataPlaneTcpReadResult(val data: ByteArray)
/** Result of a completed UDP bind op. */
data class DataPlaneUdpBindResult(val handle: Long, val localAddress: DataPlaneSocketAddress)
/** Result of a completed UDP recv_from op. */
data class DataPlaneUdpRecvResult(
val data: ByteArray,
val peerAddress: DataPlaneSocketAddress
)
/** TCP data-plane stream handle. Call [close] when the stream is no longer needed. */
class DataPlaneTcpStream(
val handle: Long,
val localAddress: DataPlaneSocketAddress? = null,
val peerAddress: DataPlaneSocketAddress? = null
) {
/** Read up to [maxLength] bytes, waiting at most [timeoutMs] in native code. */
suspend fun read(maxLength: Int, timeoutMs: Long): ByteArray =
EasyTierDataPlane.tcpRead(this, maxLength, timeoutMs)
/** Write [data], waiting at most [timeoutMs] in native code. */
suspend fun write(data: ByteArray, timeoutMs: Long): Int =
EasyTierDataPlane.tcpWrite(this, data, timeoutMs)
/** Close the native TCP stream handle. */
fun close(): Int = EasyTierDataPlaneJNI.dataPlaneTcpClose(handle)
}
/** TCP data-plane listener handle. Call [close] when the listener is no longer needed. */
class DataPlaneTcpListener(val handle: Long, val localAddress: DataPlaneSocketAddress) {
/** Accept one TCP data-plane stream. */
suspend fun accept(timeoutMs: Long): DataPlaneTcpStream =
EasyTierDataPlane.tcpAccept(this, timeoutMs)
/** Close the native TCP listener handle. */
fun close(): Int = EasyTierDataPlaneJNI.dataPlaneTcpListenerClose(handle)
}
/** UDP data-plane socket handle. Call [close] when the socket is no longer needed. */
class DataPlaneUdpSocket(val handle: Long, val localAddress: DataPlaneSocketAddress) {
/** Send one UDP datagram to [dstIp]:[dstPort]. */
suspend fun sendTo(
dstIp: String,
dstPort: Int,
data: ByteArray,
timeoutMs: Long
): Int = EasyTierDataPlane.udpSendTo(this, dstIp, dstPort, data, timeoutMs)
/** Receive one UDP datagram and its peer address. */
suspend fun recvFrom(maxLength: Int, timeoutMs: Long): DataPlaneUdpRecvResult =
EasyTierDataPlane.udpRecvFrom(this, maxLength, timeoutMs)
/** Close the native UDP socket handle. */
fun close(): Int = EasyTierDataPlaneJNI.dataPlaneUdpClose(handle)
}
/**
* Low-level native data-plane JNI entry points.
*
* These functions mirror the Rust FFI op-handle ABI directly. They are exposed
* for completeness, but most Android callers should use [EasyTierDataPlane]
* instead so coroutine cancellation and op cleanup are handled consistently.
*/
object EasyTierDataPlaneJNI {
init {
System.loadLibrary("easytier_android_jni")
}
@JvmStatic external fun dataPlaneAsyncOpStatus(handle: Long): Int
@JvmStatic external fun dataPlaneAsyncOpWait(handle: Long, timeoutMs: Long): Int
@JvmStatic external fun dataPlaneAsyncOpCancel(handle: Long): Int
@JvmStatic external fun dataPlaneAsyncOpFree(handle: Long): Int
@JvmStatic
external fun dataPlaneTcpConnectStart(
instanceName: String,
dstIp: String,
dstPort: Int,
timeoutMs: Long
): Long
@JvmStatic external fun dataPlaneTcpConnectFinish(op: Long): DataPlaneTcpConnectResult?
@JvmStatic
external fun dataPlaneTcpBindStart(
instanceName: String,
localPort: Int,
timeoutMs: Long
): Long
@JvmStatic external fun dataPlaneTcpBindFinish(op: Long): DataPlaneTcpBindResult?
@JvmStatic external fun dataPlaneTcpAcceptStart(handle: Long, timeoutMs: Long): Long
@JvmStatic external fun dataPlaneTcpAcceptFinish(op: Long): DataPlaneTcpAcceptResult?
@JvmStatic external fun dataPlaneTcpReadStart(handle: Long, maxLength: Int, timeoutMs: Long): Long
@JvmStatic external fun dataPlaneTcpReadFinish(op: Long): DataPlaneTcpReadResult?
@JvmStatic external fun dataPlaneTcpWriteStart(handle: Long, data: ByteArray, timeoutMs: Long): Long
@JvmStatic external fun dataPlaneTcpWriteFinish(op: Long): Int
@JvmStatic
external fun dataPlaneUdpBindStart(
instanceName: String,
localPort: Int,
timeoutMs: Long
): Long
@JvmStatic external fun dataPlaneUdpBindFinish(op: Long): DataPlaneUdpBindResult?
@JvmStatic
external fun dataPlaneUdpSendToStart(
handle: Long,
dstIp: String,
dstPort: Int,
data: ByteArray,
timeoutMs: Long
): Long
@JvmStatic external fun dataPlaneUdpSendToFinish(op: Long): Int
@JvmStatic external fun dataPlaneUdpRecvFromStart(handle: Long, maxLength: Int, timeoutMs: Long): Long
@JvmStatic external fun dataPlaneUdpRecvFromFinish(op: Long): DataPlaneUdpRecvResult?
@JvmStatic external fun dataPlaneTcpClose(handle: Long): Int
@JvmStatic external fun dataPlaneTcpListenerClose(handle: Long): Int
@JvmStatic external fun dataPlaneUdpClose(handle: Long): Int
}
/** Coroutine-friendly Android data-plane API. */
object EasyTierDataPlane {
private const val DATA_PLANE_OP_PENDING = 0
private const val DATA_PLANE_OP_READY = 1
private const val DATA_PLANE_OP_FAILED = -1
private const val DATA_PLANE_OP_INVALID = -2
private const val DATA_PLANE_WAIT_SLICE_MS = 50L
/** Connect to a TCP endpoint through the named EasyTier instance. */
@JvmStatic
suspend fun tcpConnect(
instanceName: String,
dstIp: String,
dstPort: Int,
timeoutMs: Long
): DataPlaneTcpStream {
val op =
requireOp(
EasyTierDataPlaneJNI.dataPlaneTcpConnectStart(
instanceName,
dstIp,
dstPort,
timeoutMs
)
)
val result = awaitOp(op) {
EasyTierDataPlaneJNI.dataPlaneTcpConnectFinish(it) ?: throw lastDataPlaneException()
}
return DataPlaneTcpStream(result.handle, result.localAddress)
}
/** Bind a TCP data-plane listener on [localPort]. Port 0 asks EasyTier to allocate one. */
@JvmStatic
suspend fun tcpBind(
instanceName: String,
localPort: Int,
timeoutMs: Long
): DataPlaneTcpListener {
val op =
requireOp(
EasyTierDataPlaneJNI.dataPlaneTcpBindStart(
instanceName,
localPort,
timeoutMs
)
)
val result = awaitOp(op) {
EasyTierDataPlaneJNI.dataPlaneTcpBindFinish(it) ?: throw lastDataPlaneException()
}
return DataPlaneTcpListener(result.handle, result.localAddress)
}
/** Accept one TCP stream from [listener]. */
@JvmStatic
suspend fun tcpAccept(listener: DataPlaneTcpListener, timeoutMs: Long): DataPlaneTcpStream {
val op =
requireOp(
EasyTierDataPlaneJNI.dataPlaneTcpAcceptStart(listener.handle, timeoutMs)
)
val result = awaitOp(op) {
EasyTierDataPlaneJNI.dataPlaneTcpAcceptFinish(it) ?: throw lastDataPlaneException()
}
return DataPlaneTcpStream(result.handle, result.localAddress, result.peerAddress)
}
/** Read up to [maxLength] bytes from [stream]. */
@JvmStatic
suspend fun tcpRead(
stream: DataPlaneTcpStream,
maxLength: Int,
timeoutMs: Long
): ByteArray {
val op =
requireOp(
EasyTierDataPlaneJNI.dataPlaneTcpReadStart(
stream.handle,
maxLength,
timeoutMs
)
)
return awaitOp(op) {
EasyTierDataPlaneJNI.dataPlaneTcpReadFinish(it)?.data
?: throw lastDataPlaneException()
}
}
/** Write [data] to [stream]. */
@JvmStatic
suspend fun tcpWrite(stream: DataPlaneTcpStream, data: ByteArray, timeoutMs: Long): Int {
val op =
requireOp(
EasyTierDataPlaneJNI.dataPlaneTcpWriteStart(
stream.handle,
data,
timeoutMs
)
)
return awaitOp(op) { EasyTierDataPlaneJNI.dataPlaneTcpWriteFinish(it) }
}
/** Bind a UDP data-plane socket on [localPort]. Port 0 asks EasyTier to allocate one. */
@JvmStatic
suspend fun udpBind(
instanceName: String,
localPort: Int,
timeoutMs: Long
): DataPlaneUdpSocket {
val op =
requireOp(
EasyTierDataPlaneJNI.dataPlaneUdpBindStart(
instanceName,
localPort,
timeoutMs
)
)
val result = awaitOp(op) {
EasyTierDataPlaneJNI.dataPlaneUdpBindFinish(it) ?: throw lastDataPlaneException()
}
return DataPlaneUdpSocket(result.handle, result.localAddress)
}
/** Send one UDP datagram through [socket]. */
@JvmStatic
suspend fun udpSendTo(
socket: DataPlaneUdpSocket,
dstIp: String,
dstPort: Int,
data: ByteArray,
timeoutMs: Long
): Int {
val op =
requireOp(
EasyTierDataPlaneJNI.dataPlaneUdpSendToStart(
socket.handle,
dstIp,
dstPort,
data,
timeoutMs
)
)
return awaitOp(op) { EasyTierDataPlaneJNI.dataPlaneUdpSendToFinish(it) }
}
/** Receive one UDP datagram through [socket]. */
@JvmStatic
suspend fun udpRecvFrom(
socket: DataPlaneUdpSocket,
maxLength: Int,
timeoutMs: Long
): DataPlaneUdpRecvResult {
val op =
requireOp(
EasyTierDataPlaneJNI.dataPlaneUdpRecvFromStart(
socket.handle,
maxLength,
timeoutMs
)
)
return awaitOp(op) {
EasyTierDataPlaneJNI.dataPlaneUdpRecvFromFinish(it) ?: throw lastDataPlaneException()
}
}
private fun requireOp(op: Long): Long {
if (op == 0L) {
throw lastDataPlaneException()
}
return op
}
private suspend fun <T> awaitOp(op: Long, finish: (Long) -> T): T =
withContext(Dispatchers.IO) {
var consumed = false
try {
awaitReady(op)
val result = finish(op)
consumed = true
result
} catch (e: CancellationException) {
EasyTierDataPlaneJNI.dataPlaneAsyncOpCancel(op)
throw e
} finally {
if (!consumed) {
EasyTierDataPlaneJNI.dataPlaneAsyncOpFree(op)
}
}
}
private suspend fun awaitReady(op: Long) {
while (true) {
currentCoroutineContext().ensureActive()
when (EasyTierDataPlaneJNI.dataPlaneAsyncOpWait(op, DATA_PLANE_WAIT_SLICE_MS)) {
DATA_PLANE_OP_READY, DATA_PLANE_OP_FAILED -> return
DATA_PLANE_OP_PENDING -> Unit
DATA_PLANE_OP_INVALID -> throw RuntimeException("Data-plane async operation is invalid")
else -> throw RuntimeException("Unknown data-plane async operation status")
}
}
}
private fun lastDataPlaneException(): RuntimeException {
return RuntimeException(EasyTierJNI.getLastError() ?: "EasyTier data-plane call failed")
}
}
@@ -1,8 +1,11 @@
package com.easytier.jni
/** EasyTier JNI 接口类 提供 Android 应用调用 EasyTier 网络功能的接口 */
object EasyTierJNI {
fun interface ConfigServerEventCallback {
fun onEvent(eventJson: String)
}
/** EasyTier JNI 接口类 提供 Android 应用调用 EasyTier 核心网络功能的接口 */
object EasyTierJNI {
init {
// 加载本地库
System.loadLibrary("easytier_android_jni")
@@ -33,6 +36,35 @@ object EasyTierJNI {
*/
@JvmStatic external fun runNetworkInstance(config: String): Int
/**
* 启动配置服务器客户端
* @param url 配置服务器 URL
* @param hostname 主机名,传入 null 使用系统主机名
* @param machineId 稳定机器 ID,由调用方负责持久化
* @param secureMode 是否启用 secure mode
* @param callback 远程配置应用/删除事件回调
* @return 0 表示成功,-1 表示失败
* @throws RuntimeException 当客户端启动失败时抛出异常
*/
@JvmStatic
external fun startConfigServerClient(
url: String,
hostname: String?,
machineId: String,
secureMode: Boolean,
callback: ConfigServerEventCallback?
): Int
/**
* 停止配置服务器客户端
* @return 0 表示成功,-1 表示失败
* @throws RuntimeException 当客户端停止失败时抛出异常
*/
@JvmStatic external fun stopConfigServerClient(): Int
/** 查询配置服务器客户端是否已连接 */
@JvmStatic external fun isConfigServerClientConnected(): Boolean
/**
* 保留指定的网络实例,停止其他实例
* @param instanceNames 要保留的实例名称数组,传入 null 或空数组将停止所有实例
@@ -44,11 +76,48 @@ object EasyTierJNI {
/**
* 收集网络信息
* @param maxLength 最大返回条目数
* @return 包含网络信息的字符串数组,每个元素格式为 "key=value"
* @return 包含网络信息的 JSON 字符串
* @throws RuntimeException 当操作失败时抛出异常
*/
@JvmStatic external fun collectNetworkInfos(maxLength: Int): String?
/**
* 列出当前运行的实例名称和实例 ID。
* @param maxLength 最大返回条目数
* @return JSON 对象,key 为 instance namevalue 为 instance id
* @throws RuntimeException 当操作失败时抛出异常
*/
@JvmStatic external fun listInstances(maxLength: Int): String?
/**
* 调用暴露的 EasyTier RPC 方法,输入和输出均为 protobuf JSON 字符串。
*
* 不支持 api.manage.WebClientService;实例启动、保留、删除、信息收集请继续使用专用 JNI API。
* payloadJson 需要包含目标 RPC 所需的 instance selector。
*
* @param serviceName RPC 服务名,例如 api.instance.PeerManageRpcService
* @param methodName RPC 方法名,支持 snake_case 或 proto 方法名
* @param domainName 仅 TcpProxyRpcService 使用;传 null 或空字符串默认 tcp
* @param payloadJson protobuf JSON 请求体
* @return protobuf JSON 响应体
* @throws RuntimeException 当 RPC 调用失败时抛出异常
*/
@JvmStatic
external fun callJsonRpc(
serviceName: String,
methodName: String,
domainName: String?,
payloadJson: String
): String?
/**
* 调用不需要 domainName 的 EasyTier RPC 方法。
*/
@JvmStatic
fun callJsonRpc(serviceName: String, methodName: String, payloadJson: String): String? {
return callJsonRpc(serviceName, methodName, null, payloadJson)
}
/**
* 获取最后的错误消息
* @return 错误消息字符串,如果没有错误则返回 null
@@ -0,0 +1,124 @@
use std::{
ffi::{CStr, c_char, c_void},
sync::{Arc, Mutex, MutexGuard},
};
use easytier_ffi::ConfigServerEventCallback;
use jni::JNIEnv;
use jni::objects::{GlobalRef, JObject, JValue};
use once_cell::sync::Lazy;
use crate::error;
pub(crate) struct JniConfigServerCallback {
java_vm: jni::JavaVM,
callback: GlobalRef,
}
static CONFIG_SERVER_CALLBACK: Lazy<Mutex<Option<Arc<JniConfigServerCallback>>>> =
Lazy::new(|| Mutex::new(None));
pub(crate) fn lock_callback_storage()
-> Result<MutexGuard<'static, Option<Arc<JniConfigServerCallback>>>, String> {
CONFIG_SERVER_CALLBACK
.lock()
.map_err(|e| format!("Failed to lock config server callback: {}", e))
}
pub(crate) fn new_callback(
env: &mut JNIEnv,
callback: &JObject,
) -> Result<Arc<JniConfigServerCallback>, String> {
let java_vm = env
.get_java_vm()
.map_err(|e| format!("Failed to get JavaVM: {:?}", e))?;
let callback = env
.new_global_ref(callback)
.map_err(|e| format!("Failed to create callback global ref: {:?}", e))?;
Ok(Arc::new(JniConfigServerCallback { java_vm, callback }))
}
pub(crate) fn callback_fn(
callback: &Option<Arc<JniConfigServerCallback>>,
) -> ConfigServerEventCallback {
callback
.as_ref()
.map(|_| config_server_event_callback as unsafe extern "C" fn(*const c_char, *mut c_void))
}
pub(crate) fn user_data(callback: &Option<Arc<JniConfigServerCallback>>) -> *mut c_void {
callback
.as_ref()
.map(|callback| Arc::as_ptr(callback) as *mut c_void)
.unwrap_or(std::ptr::null_mut())
}
impl JniConfigServerCallback {
fn clear_pending_exception(
env: &mut JNIEnv,
context: &str,
error: &dyn std::fmt::Debug,
) -> String {
match env.exception_check() {
Ok(true) => {
if let Err(clear_err) = env.exception_clear() {
return format!(
"{}: {:?}; failed to clear pending Java exception: {:?}",
context, error, clear_err
);
}
}
Ok(false) => {}
Err(check_err) => {
return format!(
"{}: {:?}; failed to check pending Java exception: {:?}",
context, error, check_err
);
}
}
format!("{}: {:?}", context, error)
}
fn on_event(&self, event_json: *const c_char) -> Result<(), String> {
let event_json = unsafe { CStr::from_ptr(event_json) }
.to_str()
.map_err(|e| format!("Invalid config server event JSON: {:?}", e))?;
let mut env = self
.java_vm
.attach_current_thread()
.map_err(|e| format!("Failed to attach callback thread: {:?}", e))?;
let event_json = env.new_string(event_json).map_err(|e| {
Self::clear_pending_exception(&mut env, "Failed to create event string", &e)
})?;
if let Err(e) = env.call_method(
self.callback.as_obj(),
"onEvent",
"(Ljava/lang/String;)V",
&[JValue::from(&event_json)],
) {
return Err(Self::clear_pending_exception(
&mut env,
"Failed to call config server callback",
&e,
));
}
Ok(())
}
}
unsafe extern "C" fn config_server_event_callback(
event_json: *const c_char,
user_data: *mut c_void,
) {
if event_json.is_null() || user_data.is_null() {
return;
}
let callback = unsafe { &*(user_data as *const JniConfigServerCallback) };
if let Err(error) = callback.on_event(event_json) {
error::set_callback_error(error);
}
}
@@ -0,0 +1,140 @@
use std::ptr;
use easytier_ffi::{
in_config_server_callback, is_config_server_client_connected, start_config_server_client,
stop_config_server_client,
};
use jni::JNIEnv;
use jni::objects::{JClass, JObject, JString};
use jni::sys::{JNI_FALSE, JNI_TRUE, jboolean, jint};
use crate::{
callback, error,
strings::{jstring_to_cstring, optional_jstring_to_cstring},
};
pub(crate) fn start_config_server_client_jni(
env: &mut JNIEnv,
config_server_url: JString,
hostname: JString,
machine_id: JString,
secure_mode: jboolean,
callback_obj: JObject,
) -> jint {
if in_config_server_callback() {
error::throw_exception(
env,
"Cannot start config server client from config server callback",
);
return -1;
}
let config_server_url = match jstring_to_cstring(env, &config_server_url) {
Ok(cstr) => cstr,
Err(e) => {
error::throw_exception(env, &format!("Invalid config server URL: {}", e));
return -1;
}
};
let hostname = match optional_jstring_to_cstring(env, &hostname) {
Ok(cstr) => cstr,
Err(e) => {
error::throw_exception(env, &format!("Invalid hostname: {}", e));
return -1;
}
};
let machine_id = match jstring_to_cstring(env, &machine_id) {
Ok(cstr) => cstr,
Err(e) => {
error::throw_exception(env, &format!("Invalid machine ID: {}", e));
return -1;
}
};
let callback_ref = if callback_obj.is_null() {
None
} else {
match callback::new_callback(env, &callback_obj) {
Ok(state) => Some(state),
Err(e) => {
error::throw_exception(env, &e);
return -1;
}
}
};
let mut callback_guard = match callback::lock_callback_storage() {
Ok(guard) => guard,
Err(e) => {
error::throw_exception(env, &e);
return -1;
}
};
if callback_guard.is_none() {
error::clear_callback_error();
}
let callback_fn = callback::callback_fn(&callback_ref);
let user_data = callback::user_data(&callback_ref);
let result = unsafe {
start_config_server_client(
config_server_url.as_ptr(),
hostname
.as_ref()
.map(|value| value.as_ptr())
.unwrap_or(ptr::null()),
machine_id.as_ptr(),
secure_mode == JNI_TRUE,
callback_fn,
user_data,
)
};
if result != 0 {
if let Some(error_msg) = error::get_last_error() {
error::throw_exception(env, &error_msg);
}
return result;
}
*callback_guard = callback_ref;
result
}
pub(crate) fn stop_config_server_client_jni(mut env: JNIEnv, _class: JClass) -> jint {
if in_config_server_callback() {
let result = stop_config_server_client();
if result != 0
&& let Some(error_msg) = error::get_last_error()
{
error::throw_exception(&mut env, &error_msg);
}
return result;
}
let mut callback_guard = match callback::lock_callback_storage() {
Ok(guard) => guard,
Err(e) => {
error::throw_exception(&mut env, &e);
return -1;
}
};
let result = stop_config_server_client();
if result != 0 {
if let Some(error_msg) = error::get_last_error() {
error::throw_exception(&mut env, &error_msg);
}
return result;
}
*callback_guard = None;
result
}
pub(crate) fn is_config_server_client_connected_jni(_env: JNIEnv, _class: JClass) -> jboolean {
if is_config_server_client_connected() != 0 {
JNI_TRUE
} else {
JNI_FALSE
}
}
@@ -0,0 +1,673 @@
use std::{
ffi::{CStr, c_char},
ptr,
};
use easytier_ffi::{
data_plane_async_op_cancel, data_plane_async_op_free, data_plane_async_op_status,
data_plane_async_op_wait, data_plane_free_bytes, data_plane_tcp_accept_finish,
data_plane_tcp_accept_start, data_plane_tcp_bind_finish, data_plane_tcp_bind_start,
data_plane_tcp_close, data_plane_tcp_connect_finish, data_plane_tcp_connect_start,
data_plane_tcp_listener_close, data_plane_tcp_read_finish, data_plane_tcp_read_start,
data_plane_tcp_write_finish, data_plane_tcp_write_start, data_plane_udp_bind_finish,
data_plane_udp_bind_start, data_plane_udp_close, data_plane_udp_recv_from_finish,
data_plane_udp_recv_from_start, data_plane_udp_send_to_finish, data_plane_udp_send_to_start,
free_string,
};
use jni::{
JNIEnv,
objects::{JByteArray, JClass, JObject, JString, JValue},
sys::{jint, jlong, jobject},
};
use crate::{
error::{get_last_error, throw_exception},
strings::jstring_to_cstring,
};
const SOCKET_ADDR_CLASS: &str = "com/easytier/jni/DataPlaneSocketAddress";
const TCP_CONNECT_RESULT_CLASS: &str = "com/easytier/jni/DataPlaneTcpConnectResult";
const TCP_BIND_RESULT_CLASS: &str = "com/easytier/jni/DataPlaneTcpBindResult";
const TCP_ACCEPT_RESULT_CLASS: &str = "com/easytier/jni/DataPlaneTcpAcceptResult";
const TCP_READ_RESULT_CLASS: &str = "com/easytier/jni/DataPlaneTcpReadResult";
const UDP_BIND_RESULT_CLASS: &str = "com/easytier/jni/DataPlaneUdpBindResult";
const UDP_RECV_RESULT_CLASS: &str = "com/easytier/jni/DataPlaneUdpRecvResult";
fn timeout_from_jlong(timeout_ms: jlong) -> u64 {
timeout_ms.max(0) as u64
}
fn port_from_jint(env: &mut JNIEnv, value: jint, name: &str) -> Option<u16> {
match u16::try_from(value) {
Ok(port) => Some(port),
Err(_) => {
throw_exception(env, &format!("Invalid {}: {}", name, value));
None
}
}
}
fn len_from_jint(env: &mut JNIEnv, value: jint, name: &str) -> Option<u32> {
match u32::try_from(value) {
Ok(len) => Some(len),
Err(_) => {
throw_exception(env, &format!("Invalid {}: {}", name, value));
None
}
}
}
fn throw_last(env: &mut JNIEnv) {
let message = get_last_error().unwrap_or_else(|| "EasyTier data-plane call failed".to_string());
throw_exception(env, &message);
}
unsafe fn take_ffi_string(ptr: *const c_char) -> String {
if ptr.is_null() {
return String::new();
}
let value = unsafe { CStr::from_ptr(ptr) }
.to_string_lossy()
.into_owned();
free_string(ptr);
value
}
fn new_socket_addr<'local>(
env: &mut JNIEnv<'local>,
ip: String,
port: u16,
) -> Option<JObject<'local>> {
let class = match env.find_class(SOCKET_ADDR_CLASS) {
Ok(class) => class,
Err(err) => {
throw_exception(
env,
&format!("Failed to find socket address class: {:?}", err),
);
return None;
}
};
let ip = match env.new_string(ip) {
Ok(ip) => ip,
Err(err) => {
throw_exception(env, &format!("Failed to create IP string: {:?}", err));
return None;
}
};
match env.new_object(
class,
"(Ljava/lang/String;I)V",
&[JValue::Object(&ip), JValue::Int(port as jint)],
) {
Ok(addr) => Some(addr),
Err(err) => {
throw_exception(env, &format!("Failed to create socket address: {:?}", err));
None
}
}
}
fn new_handle_addr_result(
env: &mut JNIEnv,
class_name: &str,
handle: u64,
ip: String,
port: u16,
) -> jobject {
let Some(addr) = new_socket_addr(env, ip, port) else {
return ptr::null_mut();
};
let class = match env.find_class(class_name) {
Ok(class) => class,
Err(err) => {
throw_exception(env, &format!("Failed to find result class: {:?}", err));
return ptr::null_mut();
}
};
let sig = format!("(JL{};)V", SOCKET_ADDR_CLASS);
match env.new_object(
class,
sig.as_str(),
&[JValue::Long(handle as jlong), JValue::Object(&addr)],
) {
Ok(result) => result.into_raw(),
Err(err) => {
throw_exception(env, &format!("Failed to create result object: {:?}", err));
ptr::null_mut()
}
}
}
fn close_tcp_stream_on_null(result: jobject, handle: u64) -> jobject {
if result.is_null() {
let _ = data_plane_tcp_close(handle);
}
result
}
fn close_tcp_listener_on_null(result: jobject, handle: u64) -> jobject {
if result.is_null() {
let _ = data_plane_tcp_listener_close(handle);
}
result
}
fn close_udp_socket_on_null(result: jobject, handle: u64) -> jobject {
if result.is_null() {
let _ = data_plane_udp_close(handle);
}
result
}
fn read_owned_bytes(ptr: *const u8, len: u32) -> Vec<u8> {
if ptr.is_null() || len == 0 {
return Vec::new();
}
let bytes = unsafe { std::slice::from_raw_parts(ptr, len as usize) }.to_vec();
data_plane_free_bytes(ptr, len);
bytes
}
pub(crate) fn async_op_status_jni(_env: JNIEnv, _class: JClass, handle: jlong) -> jint {
data_plane_async_op_status(handle as u64)
}
pub(crate) fn async_op_wait_jni(
_env: JNIEnv,
_class: JClass,
handle: jlong,
timeout_ms: jlong,
) -> jint {
data_plane_async_op_wait(handle as u64, timeout_ms.max(0) as u64)
}
pub(crate) fn async_op_cancel_jni(_env: JNIEnv, _class: JClass, handle: jlong) -> jint {
data_plane_async_op_cancel(handle as u64)
}
pub(crate) fn async_op_free_jni(_env: JNIEnv, _class: JClass, handle: jlong) -> jint {
data_plane_async_op_free(handle as u64)
}
pub(crate) fn tcp_connect_start_jni(
mut env: JNIEnv,
_class: JClass,
inst_name: JString,
dst_ip: JString,
dst_port: jint,
timeout_ms: jlong,
) -> jlong {
let inst_name = match jstring_to_cstring(&mut env, &inst_name) {
Ok(value) => value,
Err(err) => {
throw_exception(&mut env, &format!("Invalid instance name: {}", err));
return 0;
}
};
let dst_ip = match jstring_to_cstring(&mut env, &dst_ip) {
Ok(value) => value,
Err(err) => {
throw_exception(&mut env, &format!("Invalid destination IP: {}", err));
return 0;
}
};
let Some(dst_port) = port_from_jint(&mut env, dst_port, "destination port") else {
return 0;
};
let op = unsafe {
data_plane_tcp_connect_start(
inst_name.as_ptr(),
dst_ip.as_ptr(),
dst_port,
timeout_ms.max(0) as u64,
)
};
if op == 0 {
throw_last(&mut env);
}
op as jlong
}
pub(crate) fn tcp_connect_finish_jni(mut env: JNIEnv, _class: JClass, op: jlong) -> jobject {
let mut ip: *const c_char = ptr::null();
let mut port = 0u16;
let handle = unsafe { data_plane_tcp_connect_finish(op as u64, &mut ip, &mut port) };
if handle == 0 {
throw_last(&mut env);
return ptr::null_mut();
}
close_tcp_stream_on_null(
new_handle_addr_result(
&mut env,
TCP_CONNECT_RESULT_CLASS,
handle,
unsafe { take_ffi_string(ip) },
port,
),
handle,
)
}
pub(crate) fn tcp_bind_start_jni(
mut env: JNIEnv,
_class: JClass,
inst_name: JString,
local_port: jint,
timeout_ms: jlong,
) -> jlong {
let inst_name = match jstring_to_cstring(&mut env, &inst_name) {
Ok(value) => value,
Err(err) => {
throw_exception(&mut env, &format!("Invalid instance name: {}", err));
return 0;
}
};
let Some(local_port) = port_from_jint(&mut env, local_port, "local port") else {
return 0;
};
let op = unsafe {
data_plane_tcp_bind_start(
inst_name.as_ptr(),
local_port,
timeout_from_jlong(timeout_ms),
)
};
if op == 0 {
throw_last(&mut env);
}
op as jlong
}
pub(crate) fn tcp_bind_finish_jni(mut env: JNIEnv, _class: JClass, op: jlong) -> jobject {
let mut ip: *const c_char = ptr::null();
let mut port = 0u16;
let handle = unsafe { data_plane_tcp_bind_finish(op as u64, &mut ip, &mut port) };
if handle == 0 {
throw_last(&mut env);
return ptr::null_mut();
}
close_tcp_listener_on_null(
new_handle_addr_result(
&mut env,
TCP_BIND_RESULT_CLASS,
handle,
unsafe { take_ffi_string(ip) },
port,
),
handle,
)
}
pub(crate) fn tcp_accept_start_jni(
mut env: JNIEnv,
_class: JClass,
handle: jlong,
timeout_ms: jlong,
) -> jlong {
let op = unsafe { data_plane_tcp_accept_start(handle as u64, timeout_from_jlong(timeout_ms)) };
if op == 0 {
throw_last(&mut env);
}
op as jlong
}
pub(crate) fn tcp_accept_finish_jni(mut env: JNIEnv, _class: JClass, op: jlong) -> jobject {
let mut local_ip: *const c_char = ptr::null();
let mut local_port = 0u16;
let mut peer_ip: *const c_char = ptr::null();
let mut peer_port = 0u16;
let handle = unsafe {
data_plane_tcp_accept_finish(
op as u64,
&mut local_ip,
&mut local_port,
&mut peer_ip,
&mut peer_port,
)
};
if handle == 0 {
throw_last(&mut env);
return ptr::null_mut();
}
let Some(local_addr) =
new_socket_addr(&mut env, unsafe { take_ffi_string(local_ip) }, local_port)
else {
free_string(peer_ip);
let _ = data_plane_tcp_close(handle);
return ptr::null_mut();
};
let Some(peer_addr) = new_socket_addr(&mut env, unsafe { take_ffi_string(peer_ip) }, peer_port)
else {
let _ = data_plane_tcp_close(handle);
return ptr::null_mut();
};
let class = match env.find_class(TCP_ACCEPT_RESULT_CLASS) {
Ok(class) => class,
Err(err) => {
throw_exception(
&mut env,
&format!("Failed to find accept result class: {:?}", err),
);
let _ = data_plane_tcp_close(handle);
return ptr::null_mut();
}
};
let sig = format!("(JL{};L{};)V", SOCKET_ADDR_CLASS, SOCKET_ADDR_CLASS);
let result = match env.new_object(
class,
sig.as_str(),
&[
JValue::Long(handle as jlong),
JValue::Object(&local_addr),
JValue::Object(&peer_addr),
],
) {
Ok(result) => result.into_raw(),
Err(err) => {
throw_exception(
&mut env,
&format!("Failed to create accept result: {:?}", err),
);
ptr::null_mut()
}
};
close_tcp_stream_on_null(result, handle)
}
pub(crate) fn tcp_read_start_jni(
mut env: JNIEnv,
_class: JClass,
handle: jlong,
max_len: jint,
timeout_ms: jlong,
) -> jlong {
let Some(max_len) = len_from_jint(&mut env, max_len, "max length") else {
return 0;
};
let op = unsafe {
data_plane_tcp_read_start(handle as u64, max_len, timeout_from_jlong(timeout_ms))
};
if op == 0 {
throw_last(&mut env);
}
op as jlong
}
pub(crate) fn tcp_read_finish_jni(mut env: JNIEnv, _class: JClass, op: jlong) -> jobject {
let mut ptr: *const u8 = ptr::null();
let mut len = 0u32;
let ret = unsafe { data_plane_tcp_read_finish(op as u64, &mut ptr, &mut len) };
if ret < 0 {
throw_last(&mut env);
return ptr::null_mut();
}
let bytes = read_owned_bytes(ptr, len);
let array = match env.byte_array_from_slice(&bytes) {
Ok(array) => array,
Err(err) => {
throw_exception(&mut env, &format!("Failed to create byte array: {:?}", err));
return ptr::null_mut();
}
};
let class = match env.find_class(TCP_READ_RESULT_CLASS) {
Ok(class) => class,
Err(err) => {
throw_exception(
&mut env,
&format!("Failed to find read result class: {:?}", err),
);
return ptr::null_mut();
}
};
match env.new_object(class, "([B)V", &[JValue::Object(&array)]) {
Ok(result) => result.into_raw(),
Err(err) => {
throw_exception(
&mut env,
&format!("Failed to create read result: {:?}", err),
);
ptr::null_mut()
}
}
}
pub(crate) fn tcp_write_start_jni(
mut env: JNIEnv,
_class: JClass,
handle: jlong,
data: JByteArray,
timeout_ms: jlong,
) -> jlong {
let data = match env.convert_byte_array(&data) {
Ok(data) => data,
Err(err) => {
throw_exception(&mut env, &format!("Invalid write buffer: {:?}", err));
return 0;
}
};
let ptr = if data.is_empty() {
ptr::null()
} else {
data.as_ptr()
};
let op = unsafe {
data_plane_tcp_write_start(
handle as u64,
ptr,
data.len() as u32,
timeout_from_jlong(timeout_ms),
)
};
if op == 0 {
throw_last(&mut env);
}
op as jlong
}
pub(crate) fn tcp_write_finish_jni(mut env: JNIEnv, _class: JClass, op: jlong) -> jint {
let ret = data_plane_tcp_write_finish(op as u64);
if ret < 0 {
throw_last(&mut env);
}
ret
}
pub(crate) fn udp_bind_start_jni(
mut env: JNIEnv,
_class: JClass,
inst_name: JString,
local_port: jint,
timeout_ms: jlong,
) -> jlong {
let inst_name = match jstring_to_cstring(&mut env, &inst_name) {
Ok(value) => value,
Err(err) => {
throw_exception(&mut env, &format!("Invalid instance name: {}", err));
return 0;
}
};
let Some(local_port) = port_from_jint(&mut env, local_port, "local port") else {
return 0;
};
let op = unsafe {
data_plane_udp_bind_start(
inst_name.as_ptr(),
local_port,
timeout_from_jlong(timeout_ms),
)
};
if op == 0 {
throw_last(&mut env);
}
op as jlong
}
pub(crate) fn udp_bind_finish_jni(mut env: JNIEnv, _class: JClass, op: jlong) -> jobject {
let mut ip: *const c_char = ptr::null();
let mut port = 0u16;
let handle = unsafe { data_plane_udp_bind_finish(op as u64, &mut ip, &mut port) };
if handle == 0 {
throw_last(&mut env);
return ptr::null_mut();
}
close_udp_socket_on_null(
new_handle_addr_result(
&mut env,
UDP_BIND_RESULT_CLASS,
handle,
unsafe { take_ffi_string(ip) },
port,
),
handle,
)
}
pub(crate) fn udp_send_to_start_jni(
mut env: JNIEnv,
_class: JClass,
handle: jlong,
dst_ip: JString,
dst_port: jint,
data: JByteArray,
timeout_ms: jlong,
) -> jlong {
let dst_ip = match jstring_to_cstring(&mut env, &dst_ip) {
Ok(value) => value,
Err(err) => {
throw_exception(&mut env, &format!("Invalid destination IP: {}", err));
return 0;
}
};
let Some(dst_port) = port_from_jint(&mut env, dst_port, "destination port") else {
return 0;
};
let data = match env.convert_byte_array(&data) {
Ok(data) => data,
Err(err) => {
throw_exception(&mut env, &format!("Invalid UDP send buffer: {:?}", err));
return 0;
}
};
let ptr = if data.is_empty() {
ptr::null()
} else {
data.as_ptr()
};
let op = unsafe {
data_plane_udp_send_to_start(
handle as u64,
dst_ip.as_ptr(),
dst_port,
ptr,
data.len() as u32,
timeout_from_jlong(timeout_ms),
)
};
if op == 0 {
throw_last(&mut env);
}
op as jlong
}
pub(crate) fn udp_send_to_finish_jni(mut env: JNIEnv, _class: JClass, op: jlong) -> jint {
let ret = data_plane_udp_send_to_finish(op as u64);
if ret < 0 {
throw_last(&mut env);
}
ret
}
pub(crate) fn udp_recv_from_start_jni(
mut env: JNIEnv,
_class: JClass,
handle: jlong,
max_len: jint,
timeout_ms: jlong,
) -> jlong {
let Some(max_len) = len_from_jint(&mut env, max_len, "max length") else {
return 0;
};
let op = unsafe {
data_plane_udp_recv_from_start(handle as u64, max_len, timeout_from_jlong(timeout_ms))
};
if op == 0 {
throw_last(&mut env);
}
op as jlong
}
pub(crate) fn udp_recv_from_finish_jni(mut env: JNIEnv, _class: JClass, op: jlong) -> jobject {
let mut ptr: *const u8 = ptr::null();
let mut len = 0u32;
let mut ip: *const c_char = ptr::null();
let mut port = 0u16;
let ret = unsafe {
data_plane_udp_recv_from_finish(op as u64, &mut ptr, &mut len, &mut ip, &mut port)
};
if ret < 0 {
throw_last(&mut env);
return ptr::null_mut();
}
let bytes = read_owned_bytes(ptr, len);
let array = match env.byte_array_from_slice(&bytes) {
Ok(array) => array,
Err(err) => {
free_string(ip);
throw_exception(&mut env, &format!("Failed to create byte array: {:?}", err));
return ptr::null_mut();
}
};
let Some(peer_addr) = new_socket_addr(&mut env, unsafe { take_ffi_string(ip) }, port) else {
return ptr::null_mut();
};
let class = match env.find_class(UDP_RECV_RESULT_CLASS) {
Ok(class) => class,
Err(err) => {
throw_exception(
&mut env,
&format!("Failed to find UDP recv result class: {:?}", err),
);
return ptr::null_mut();
}
};
let sig = format!("([BL{};)V", SOCKET_ADDR_CLASS);
match env.new_object(
class,
sig.as_str(),
&[JValue::Object(&array), JValue::Object(&peer_addr)],
) {
Ok(result) => result.into_raw(),
Err(err) => {
throw_exception(
&mut env,
&format!("Failed to create UDP recv result: {:?}", err),
);
ptr::null_mut()
}
}
}
pub(crate) fn tcp_close_jni(mut env: JNIEnv, _class: JClass, handle: jlong) -> jint {
let ret = data_plane_tcp_close(handle as u64);
if ret != 0 {
throw_last(&mut env);
}
ret
}
pub(crate) fn tcp_listener_close_jni(mut env: JNIEnv, _class: JClass, handle: jlong) -> jint {
let ret = data_plane_tcp_listener_close(handle as u64);
if ret != 0 {
throw_last(&mut env);
}
ret
}
pub(crate) fn udp_close_jni(mut env: JNIEnv, _class: JClass, handle: jlong) -> jint {
let ret = data_plane_udp_close(handle as u64);
if ret != 0 {
throw_last(&mut env);
}
ret
}
@@ -0,0 +1,74 @@
use std::{
ffi::{CStr, c_char},
ptr,
sync::Mutex,
};
use easytier_ffi::{free_string, get_error_msg};
use jni::JNIEnv;
use jni::objects::JClass;
use jni::sys::jstring;
use once_cell::sync::Lazy;
static JNI_CALLBACK_ERROR: Lazy<Mutex<Option<String>>> = Lazy::new(|| Mutex::new(None));
pub(crate) fn set_callback_error(error: String) {
log::error!("{}", error);
if let Ok(mut guard) = JNI_CALLBACK_ERROR.lock() {
*guard = Some(error);
}
}
pub(crate) fn clear_callback_error() {
if let Ok(mut guard) = JNI_CALLBACK_ERROR.lock() {
*guard = None;
}
}
fn take_callback_error() -> Option<String> {
JNI_CALLBACK_ERROR
.lock()
.ok()
.and_then(|mut guard| guard.take())
}
fn get_ffi_last_error() -> Option<String> {
unsafe {
let mut error_ptr: *const c_char = ptr::null();
get_error_msg(&mut error_ptr);
if error_ptr.is_null() {
None
} else {
let error_cstr = CStr::from_ptr(error_ptr);
let error_str = error_cstr.to_string_lossy().into_owned();
free_string(error_ptr);
Some(error_str)
}
}
}
pub(crate) fn get_last_error() -> Option<String> {
match (get_ffi_last_error(), take_callback_error()) {
(Some(ffi_error), Some(callback_error)) => Some(format!(
"{}; config server callback error: {}",
ffi_error, callback_error
)),
(Some(ffi_error), None) => Some(ffi_error),
(None, Some(callback_error)) => Some(callback_error),
(None, None) => None,
}
}
pub(crate) fn throw_exception(env: &mut JNIEnv, message: &str) {
let _ = env.throw_new("java/lang/RuntimeException", message);
}
pub(crate) fn get_last_error_jni(env: JNIEnv, _class: JClass) -> jstring {
match get_last_error() {
Some(error) => match env.new_string(&error) {
Ok(jstr) => jstr.into_raw(),
Err(_) => ptr::null_mut(),
},
None => ptr::null_mut(),
}
}
@@ -0,0 +1,91 @@
use std::{
ffi::{CStr, c_char},
ptr,
};
use easytier_ffi::{call_json_rpc, free_string};
use jni::JNIEnv;
use jni::objects::{JClass, JString};
use jni::sys::jstring;
use crate::{
error::{get_last_error, throw_exception},
strings::{jstring_to_cstring, optional_jstring_to_cstring},
};
pub(crate) fn call_json_rpc_jni(
mut env: JNIEnv,
_class: JClass,
service_name: JString,
method_name: JString,
domain_name: JString,
payload_json: JString,
) -> jstring {
let service_name_cstr = match jstring_to_cstring(&mut env, &service_name) {
Ok(cstr) => cstr,
Err(e) => {
throw_exception(&mut env, &format!("Invalid service name: {}", e));
return ptr::null_mut();
}
};
let method_name_cstr = match jstring_to_cstring(&mut env, &method_name) {
Ok(cstr) => cstr,
Err(e) => {
throw_exception(&mut env, &format!("Invalid method name: {}", e));
return ptr::null_mut();
}
};
let domain_name_cstr = match optional_jstring_to_cstring(&mut env, &domain_name) {
Ok(cstr) => cstr,
Err(e) => {
throw_exception(&mut env, &format!("Invalid domain name: {}", e));
return ptr::null_mut();
}
};
let payload_json_cstr = match jstring_to_cstring(&mut env, &payload_json) {
Ok(cstr) => cstr,
Err(e) => {
throw_exception(&mut env, &format!("Invalid payload JSON: {}", e));
return ptr::null_mut();
}
};
let domain_name_ptr = domain_name_cstr
.as_ref()
.map_or(ptr::null(), |cstr| cstr.as_ptr());
let mut response_ptr: *const c_char = ptr::null();
let result = unsafe {
call_json_rpc(
service_name_cstr.as_ptr(),
method_name_cstr.as_ptr(),
domain_name_ptr,
payload_json_cstr.as_ptr(),
&mut response_ptr,
)
};
if result != 0 {
if let Some(error) = get_last_error() {
throw_exception(&mut env, &error);
}
return ptr::null_mut();
}
if response_ptr.is_null() {
throw_exception(&mut env, "JSON RPC returned a null response");
return ptr::null_mut();
}
let response = unsafe { CStr::from_ptr(response_ptr) }
.to_string_lossy()
.into_owned();
free_string(response_ptr);
match env.new_string(&response) {
Ok(jstr) => jstr.into_raw(),
Err(_) => {
throw_exception(&mut env, "Failed to create JSON RPC response string");
ptr::null_mut()
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,13 @@
use once_cell::sync::Lazy;
static LOGGER_INIT: Lazy<()> = Lazy::new(|| {
android_logger::init_once(
android_logger::Config::default()
.with_max_level(log::LevelFilter::Debug)
.with_tag("EasyTier-JNI"),
);
});
pub(crate) fn init() {
Lazy::force(&LOGGER_INIT);
}
@@ -0,0 +1,261 @@
use std::{ffi::CStr, ptr};
use easytier::proto::api::manage::{NetworkInstanceRunningInfo, NetworkInstanceRunningInfoMap};
use easytier_ffi::{
KeyValuePair, collect_network_infos, free_string, list_instance, parse_config,
retain_network_instance, run_network_instance, set_tun_fd,
};
use jni::JNIEnv;
use jni::objects::{JClass, JObjectArray, JString};
use jni::sys::{jint, jstring};
use crate::{
error::{get_last_error, throw_exception},
strings::jstring_to_cstring,
};
pub(crate) fn set_tun_fd_jni(
mut env: JNIEnv,
_class: JClass,
inst_name: JString,
fd: jint,
) -> jint {
let inst_name_cstr = match jstring_to_cstring(&mut env, &inst_name) {
Ok(cstr) => cstr,
Err(e) => {
throw_exception(&mut env, &format!("Invalid instance name: {}", e));
return -1;
}
};
unsafe {
let result = set_tun_fd(inst_name_cstr.as_ptr(), fd);
if result != 0
&& let Some(error) = get_last_error()
{
throw_exception(&mut env, &error);
}
result
}
}
pub(crate) fn parse_config_jni(mut env: JNIEnv, _class: JClass, config: JString) -> jint {
let config_cstr = match jstring_to_cstring(&mut env, &config) {
Ok(cstr) => cstr,
Err(e) => {
throw_exception(&mut env, &format!("Invalid config string: {}", e));
return -1;
}
};
unsafe {
let result = parse_config(config_cstr.as_ptr());
if result != 0
&& let Some(error) = get_last_error()
{
throw_exception(&mut env, &error);
}
result
}
}
pub(crate) fn run_network_instance_jni(mut env: JNIEnv, _class: JClass, config: JString) -> jint {
let config_cstr = match jstring_to_cstring(&mut env, &config) {
Ok(cstr) => cstr,
Err(e) => {
throw_exception(&mut env, &format!("Invalid config string: {}", e));
return -1;
}
};
unsafe {
let result = run_network_instance(config_cstr.as_ptr());
if result != 0
&& let Some(error) = get_last_error()
{
throw_exception(&mut env, &error);
}
result
}
}
pub(crate) fn retain_network_instance_jni(
mut env: JNIEnv,
_class: JClass,
instance_names: JObjectArray,
) -> jint {
if instance_names.is_null() {
return retain_all(&mut env);
}
let array_length = match env.get_array_length(&instance_names) {
Ok(len) => len as usize,
Err(e) => {
throw_exception(&mut env, &format!("Failed to get array length: {:?}", e));
return -1;
}
};
if array_length == 0 {
return retain_all(&mut env);
}
let mut c_strings = Vec::with_capacity(array_length);
let mut c_string_ptrs = Vec::with_capacity(array_length);
for i in 0..array_length {
let java_string = match env.get_object_array_element(&instance_names, i as i32) {
Ok(obj) => obj,
Err(e) => {
throw_exception(
&mut env,
&format!("Failed to get array element {}: {:?}", i, e),
);
return -1;
}
};
if java_string.is_null() {
throw_exception(
&mut env,
&format!("Invalid instance name at index {}: null", i),
);
return -1;
}
let jstring = JString::from(java_string);
let c_string = match jstring_to_cstring(&mut env, &jstring) {
Ok(cstr) => cstr,
Err(e) => {
throw_exception(
&mut env,
&format!("Invalid instance name at index {}: {}", i, e),
);
return -1;
}
};
c_string_ptrs.push(c_string.as_ptr());
c_strings.push(c_string);
}
unsafe {
let result = retain_network_instance(c_string_ptrs.as_ptr(), c_string_ptrs.len());
if result != 0
&& let Some(error) = get_last_error()
{
throw_exception(&mut env, &error);
}
result
}
}
fn retain_all(env: &mut JNIEnv) -> jint {
unsafe {
let result = retain_network_instance(ptr::null(), 0);
if result != 0
&& let Some(error) = get_last_error()
{
throw_exception(env, &error);
}
result
}
}
pub(crate) fn collect_network_infos_jni(
mut env: JNIEnv,
_class: JClass,
max_length: jint,
) -> jstring {
let max_length = max_length.max(0) as usize;
let mut infos = vec![
KeyValuePair {
key: ptr::null(),
value: ptr::null(),
};
max_length
];
unsafe {
let count = collect_network_infos(infos.as_mut_ptr(), max_length);
if count < 0 {
if let Some(error) = get_last_error() {
throw_exception(&mut env, &error);
}
return ptr::null_mut();
}
let mut ret = NetworkInstanceRunningInfoMap::default();
for info in infos.iter().take(count as usize) {
let key_ptr = info.key;
let val_ptr = info.value;
if key_ptr.is_null() || val_ptr.is_null() {
break;
}
let key = CStr::from_ptr(key_ptr).to_string_lossy().into_owned();
let val = CStr::from_ptr(val_ptr).to_string_lossy().into_owned();
free_string(key_ptr);
free_string(val_ptr);
let value = match serde_json::from_str::<NetworkInstanceRunningInfo>(&val) {
Ok(v) => v,
Err(_) => {
throw_exception(&mut env, "Failed to parse JSON");
continue;
}
};
ret.map.insert(key, value);
}
let json_str = serde_json::to_string(&ret).unwrap_or_else(|_| "{}".to_string());
match env.new_string(&json_str) {
Ok(jstr) => jstr.into_raw(),
Err(_) => {
throw_exception(&mut env, "Failed to create JSON string");
ptr::null_mut()
}
}
}
}
pub(crate) fn list_instances_jni(mut env: JNIEnv, _class: JClass, max_length: jint) -> jstring {
let max_length = max_length.max(0) as usize;
let mut infos = vec![
KeyValuePair {
key: ptr::null(),
value: ptr::null(),
};
max_length
];
unsafe {
let count = list_instance(infos.as_mut_ptr(), max_length);
if count < 0 {
if let Some(error) = get_last_error() {
throw_exception(&mut env, &error);
}
return ptr::null_mut();
}
let mut ret = serde_json::Map::new();
for info in infos.iter().take(count as usize) {
let key_ptr = info.key;
let val_ptr = info.value;
if key_ptr.is_null() || val_ptr.is_null() {
break;
}
let key = CStr::from_ptr(key_ptr).to_string_lossy().into_owned();
let val = CStr::from_ptr(val_ptr).to_string_lossy().into_owned();
free_string(key_ptr);
free_string(val_ptr);
ret.insert(key, serde_json::Value::String(val));
}
let json_str = serde_json::Value::Object(ret).to_string();
match env.new_string(&json_str) {
Ok(jstr) => jstr.into_raw(),
Err(_) => {
throw_exception(&mut env, "Failed to create instance list JSON string");
ptr::null_mut()
}
}
}
}
@@ -0,0 +1,23 @@
use std::ffi::CString;
use jni::JNIEnv;
use jni::objects::JString;
pub(crate) fn jstring_to_cstring(env: &mut JNIEnv, jstr: &JString) -> Result<CString, String> {
let java_str = env
.get_string(jstr)
.map_err(|e| format!("Failed to get string: {:?}", e))?;
let rust_str = java_str.to_str().map_err(|_| "Invalid UTF-8".to_string())?;
CString::new(rust_str).map_err(|_| "String contains null byte".to_string())
}
pub(crate) fn optional_jstring_to_cstring(
env: &mut JNIEnv,
jstr: &JString,
) -> Result<Option<CString>, String> {
if jstr.is_null() {
return Ok(None);
}
jstring_to_cstring(env, jstr).map(Some)
}
+13 -2
View File
@@ -1,17 +1,28 @@
[package]
name = "easytier-ffi"
version = "0.1.0"
edition = "2021"
edition.workspace = true
[lib]
crate-type = ["cdylib"]
crate-type = ["cdylib", "rlib"]
[features]
default = ["c-abi", "ffi-dataplane"]
c-abi = []
ffi-dataplane = ["easytier/ffi-dataplane"]
[dependencies]
easytier = { path = "../../easytier" }
once_cell = "1.18.0"
dashmap = "6.0"
tokio = { version = "1", features = ["rt-multi-thread", "io-util", "time", "sync", "macros"] }
async-trait = "0.1"
log = "0.4"
percent-encoding = "2.3"
url = "2"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1"
uuid = "1.17.0"
tokio-util = "0.7"
@@ -0,0 +1,429 @@
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define DATA_PLANE_OP_PENDING 0
#define DATA_PLANE_OP_READY 1
#define DATA_PLANE_OP_FAILED -1
#define DATA_PLANE_OP_INVALID -2
extern int run_network_instance(const char *cfg_str);
extern void get_error_msg(const char **out);
extern void free_string(const char *s);
extern int data_plane_async_op_status(uint64_t op);
extern int data_plane_async_op_wait(uint64_t op, uint64_t timeout_ms);
extern int data_plane_async_op_cancel(uint64_t op);
extern int data_plane_async_op_free(uint64_t op);
extern void data_plane_free_bytes(const uint8_t *ptr, uint32_t len);
extern uint64_t data_plane_tcp_connect_start(
const char *inst_name,
const char *dst_ip,
uint16_t dst_port,
uint64_t timeout_ms);
extern uint64_t data_plane_tcp_connect_finish(
uint64_t op,
const char **out_local_ip,
uint16_t *out_local_port);
extern uint64_t data_plane_tcp_bind_start(
const char *inst_name,
uint16_t local_port,
uint64_t timeout_ms);
extern uint64_t data_plane_tcp_bind_finish(
uint64_t op,
const char **out_local_ip,
uint16_t *out_local_port);
extern uint64_t data_plane_tcp_accept_start(uint64_t listener, uint64_t timeout_ms);
extern uint64_t data_plane_tcp_accept_finish(
uint64_t op,
const char **out_local_ip,
uint16_t *out_local_port,
const char **out_peer_ip,
uint16_t *out_peer_port);
extern uint64_t data_plane_tcp_read_start(
uint64_t stream,
uint32_t max_len,
uint64_t timeout_ms);
extern int data_plane_tcp_read_finish(
uint64_t op,
const uint8_t **out_buf,
uint32_t *out_len);
extern uint64_t data_plane_tcp_write_start(
uint64_t stream,
const uint8_t *buf,
uint32_t len,
uint64_t timeout_ms);
extern int data_plane_tcp_write_finish(uint64_t op);
extern int data_plane_tcp_close(uint64_t stream);
extern int data_plane_tcp_listener_close(uint64_t listener);
extern uint64_t data_plane_udp_bind_start(
const char *inst_name,
uint16_t local_port,
uint64_t timeout_ms);
extern uint64_t data_plane_udp_bind_finish(
uint64_t op,
const char **out_local_ip,
uint16_t *out_local_port);
extern uint64_t data_plane_udp_send_to_start(
uint64_t socket,
const char *dst_ip,
uint16_t dst_port,
const uint8_t *buf,
uint32_t len,
uint64_t timeout_ms);
extern int data_plane_udp_send_to_finish(uint64_t op);
extern uint64_t data_plane_udp_recv_from_start(
uint64_t socket,
uint32_t max_len,
uint64_t timeout_ms);
extern int data_plane_udp_recv_from_finish(
uint64_t op,
const uint8_t **out_buf,
uint32_t *out_len,
const char **out_ip,
uint16_t *out_port);
extern int data_plane_udp_close(uint64_t socket);
static void print_last_error(const char *prefix) {
const char *err = NULL;
get_error_msg(&err);
if (err) {
fprintf(stderr, "%s: %s\n", prefix, err);
free_string(err);
} else {
fprintf(stderr, "%s\n", prefix);
}
}
static int parse_ip_port(const char *value, char *ip, size_t ip_len, uint16_t *port) {
const char *colon = strrchr(value, ':');
if (!colon || colon == value || !colon[1]) {
fprintf(stderr, "expected IPv4 target in IP:PORT form, got %s\n", value);
return -1;
}
size_t host_len = (size_t)(colon - value);
if (host_len >= ip_len) {
fprintf(stderr, "IP address is too long: %s\n", value);
return -1;
}
char *end = NULL;
long parsed_port = strtol(colon + 1, &end, 10);
if (!end || *end != '\0' || parsed_port < 0 || parsed_port > 65535) {
fprintf(stderr, "invalid port in %s\n", value);
return -1;
}
memcpy(ip, value, host_len);
ip[host_len] = '\0';
*port = (uint16_t)parsed_port;
return 0;
}
static int wait_op(uint64_t op, uint64_t timeout_ms) {
uint64_t waited = 0;
while (waited < timeout_ms) {
int status = data_plane_async_op_wait(op, 50);
if (status != DATA_PLANE_OP_PENDING) {
return status;
}
waited += 50;
}
return data_plane_async_op_status(op);
}
static int wait_or_cancel(uint64_t op, uint64_t timeout_ms, const char *what) {
int status = wait_op(op, timeout_ms);
if (status == DATA_PLANE_OP_READY || status == DATA_PLANE_OP_FAILED) {
return status;
}
if (status == DATA_PLANE_OP_PENDING) {
fprintf(stderr, "%s did not finish within %llu ms\n", what, (unsigned long long)timeout_ms);
data_plane_async_op_cancel(op);
data_plane_async_op_free(op);
return DATA_PLANE_OP_INVALID;
}
fprintf(stderr, "%s returned invalid op status %d\n", what, status);
return status;
}
static int async_tcp_read_once(uint64_t stream, uint64_t timeout_ms) {
uint64_t op = data_plane_tcp_read_start(stream, 512, timeout_ms);
if (!op) {
print_last_error("tcp read start failed");
return -1;
}
if (wait_or_cancel(op, timeout_ms + 1000, "tcp read") == DATA_PLANE_OP_INVALID) {
return -1;
}
const uint8_t *buf = NULL;
uint32_t len = 0;
int ret = data_plane_tcp_read_finish(op, &buf, &len);
if (ret < 0) {
print_last_error("tcp read finish failed");
return -1;
}
printf("tcp read %d bytes: %.*s\n", ret, ret, buf ? (const char *)buf : "");
data_plane_free_bytes(buf, len);
return 0;
}
static int async_tcp_write_all(uint64_t stream, const char *data, uint64_t timeout_ms) {
uint64_t op = data_plane_tcp_write_start(
stream,
(const uint8_t *)data,
(uint32_t)strlen(data),
timeout_ms);
if (!op) {
print_last_error("tcp write start failed");
return -1;
}
if (wait_or_cancel(op, timeout_ms + 1000, "tcp write") == DATA_PLANE_OP_INVALID) {
return -1;
}
int ret = data_plane_tcp_write_finish(op);
if (ret < 0) {
print_last_error("tcp write finish failed");
return -1;
}
printf("tcp wrote %d bytes\n", ret);
return 0;
}
static int run_tcp_connect_demo(const char *inst, const char *target) {
char ip[128];
uint16_t port = 0;
if (parse_ip_port(target, ip, sizeof(ip), &port) != 0) {
return -1;
}
uint64_t op = data_plane_tcp_connect_start(inst, ip, port, 30000);
if (!op) {
print_last_error("tcp connect start failed");
return -1;
}
if (wait_or_cancel(op, 31000, "tcp connect") == DATA_PLANE_OP_INVALID) {
return -1;
}
const char *local_ip = NULL;
uint16_t local_port = 0;
uint64_t stream = data_plane_tcp_connect_finish(op, &local_ip, &local_port);
if (!stream) {
print_last_error("tcp connect finish failed");
return -1;
}
printf("tcp connected from %s:%u to %s:%u, handle=%llu\n",
local_ip,
local_port,
ip,
port,
(unsigned long long)stream);
free_string(local_ip);
int ret = async_tcp_read_once(stream, 10000);
data_plane_tcp_close(stream);
return ret;
}
static int run_tcp_listen_demo(const char *inst, const char *port_text) {
uint16_t port = (uint16_t)strtoul(port_text, NULL, 10);
uint64_t op = data_plane_tcp_bind_start(inst, port, 30000);
if (!op) {
print_last_error("tcp bind start failed");
return -1;
}
if (wait_or_cancel(op, 31000, "tcp bind") == DATA_PLANE_OP_INVALID) {
return -1;
}
const char *local_ip = NULL;
uint16_t local_port = 0;
uint64_t listener = data_plane_tcp_bind_finish(op, &local_ip, &local_port);
if (!listener) {
print_last_error("tcp bind finish failed");
return -1;
}
printf("tcp listening on %s:%u, handle=%llu\n",
local_ip,
local_port,
(unsigned long long)listener);
free_string(local_ip);
op = data_plane_tcp_accept_start(listener, 60000);
if (!op) {
print_last_error("tcp accept start failed");
data_plane_tcp_listener_close(listener);
return -1;
}
if (wait_or_cancel(op, 61000, "tcp accept") == DATA_PLANE_OP_INVALID) {
data_plane_tcp_listener_close(listener);
return -1;
}
const char *peer_ip = NULL;
uint16_t peer_port = 0;
local_ip = NULL;
local_port = 0;
uint64_t stream = data_plane_tcp_accept_finish(
op,
&local_ip,
&local_port,
&peer_ip,
&peer_port);
data_plane_tcp_listener_close(listener);
if (!stream) {
print_last_error("tcp accept finish failed");
return -1;
}
printf("tcp accepted %s:%u -> %s:%u, stream=%llu\n",
peer_ip,
peer_port,
local_ip,
local_port,
(unsigned long long)stream);
free_string(local_ip);
free_string(peer_ip);
int ret = async_tcp_read_once(stream, 10000);
if (ret == 0) {
ret = async_tcp_write_all(stream, "pong", 10000);
}
data_plane_tcp_close(stream);
return ret;
}
static int run_udp_demo(const char *inst, const char *target) {
char ip[128];
uint16_t port = 0;
if (parse_ip_port(target, ip, sizeof(ip), &port) != 0) {
return -1;
}
uint64_t op = data_plane_udp_bind_start(inst, 0, 30000);
if (!op) {
print_last_error("udp bind start failed");
return -1;
}
if (wait_or_cancel(op, 31000, "udp bind") == DATA_PLANE_OP_INVALID) {
return -1;
}
const char *local_ip = NULL;
uint16_t local_port = 0;
uint64_t socket = data_plane_udp_bind_finish(op, &local_ip, &local_port);
if (!socket) {
print_last_error("udp bind finish failed");
return -1;
}
printf("udp bound on %s:%u, handle=%llu\n",
local_ip,
local_port,
(unsigned long long)socket);
free_string(local_ip);
const char payload[] = "ping";
op = data_plane_udp_send_to_start(
socket,
ip,
port,
(const uint8_t *)payload,
(uint32_t)strlen(payload),
10000);
if (!op) {
print_last_error("udp send start failed");
data_plane_udp_close(socket);
return -1;
}
if (wait_or_cancel(op, 11000, "udp send") == DATA_PLANE_OP_INVALID) {
data_plane_udp_close(socket);
return -1;
}
int sent = data_plane_udp_send_to_finish(op);
if (sent < 0) {
print_last_error("udp send finish failed");
data_plane_udp_close(socket);
return -1;
}
printf("udp sent %d bytes to %s:%u\n", sent, ip, port);
op = data_plane_udp_recv_from_start(socket, 512, 30000);
if (!op) {
print_last_error("udp recv start failed");
data_plane_udp_close(socket);
return -1;
}
if (wait_or_cancel(op, 31000, "udp recv") == DATA_PLANE_OP_INVALID) {
data_plane_udp_close(socket);
return -1;
}
const uint8_t *buf = NULL;
uint32_t len = 0;
const char *peer_ip = NULL;
uint16_t peer_port = 0;
int ret = data_plane_udp_recv_from_finish(op, &buf, &len, &peer_ip, &peer_port);
if (ret < 0) {
print_last_error("udp recv finish failed");
data_plane_udp_close(socket);
return -1;
}
printf("udp received %d bytes from %s:%u: %.*s\n",
ret,
peer_ip,
peer_port,
ret,
buf ? (const char *)buf : "");
data_plane_free_bytes(buf, len);
free_string(peer_ip);
data_plane_udp_close(socket);
return 0;
}
static void print_usage(void) {
printf("Set EASYTIER_FFI_CONFIG and EASYTIER_FFI_INSTANCE to run the async data-plane demo.\n");
printf("Optional demos:\n");
printf(" EASYTIER_FFI_TARGET=10.0.0.2:22 async TCP connect/read\n");
printf(" EASYTIER_FFI_LISTEN_PORT=12345 async TCP bind/accept/read/write\n");
printf(" EASYTIER_FFI_UDP_TARGET=10.0.0.2:9000 async UDP bind/send_to/recv_from\n");
}
int main(void) {
const char *config = getenv("EASYTIER_FFI_CONFIG");
const char *instance = getenv("EASYTIER_FFI_INSTANCE");
if (!config || !instance) {
print_usage();
return 0;
}
if (run_network_instance(config) != 0) {
print_last_error("run_network_instance failed");
return 1;
}
printf("network instance started: %s\n", instance);
int failed = 0;
const char *target = getenv("EASYTIER_FFI_TARGET");
if (target) {
failed |= run_tcp_connect_demo(instance, target) != 0;
}
const char *listen_port = getenv("EASYTIER_FFI_LISTEN_PORT");
if (listen_port) {
failed |= run_tcp_listen_demo(instance, listen_port) != 0;
}
const char *udp_target = getenv("EASYTIER_FFI_UDP_TARGET");
if (udp_target) {
failed |= run_udp_demo(instance, udp_target) != 0;
}
if (!target && !listen_port && !udp_target) {
printf("No dataplane demo env var was set; nothing else to run.\n");
print_usage();
}
return failed ? 1 : 0;
}
@@ -0,0 +1,100 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
#include <unistd.h> // for sleep
// FFI struct and function declarations
typedef struct {
const char* key;
const char* value;
} KeyValuePair;
typedef void (*config_server_event_callback)(
const char* event_json,
void* user_data
);
extern int parse_config(const char* cfg_str);
extern int run_network_instance(const char* cfg_str);
extern void get_error_msg(const char** out);
extern void free_string(const char* s);
extern int collect_network_infos(KeyValuePair* infos, size_t max_length);
extern int start_config_server_client(
const char* config_server_url,
const char* hostname,
const char* machine_id,
bool secure_mode,
config_server_event_callback callback,
void* user_data
);
extern int stop_config_server_client(void);
extern int is_config_server_client_connected(void);
static void on_config_server_event(const char* event_json, void* user_data) {
(void)user_data;
printf("config server event: %s\n", event_json);
}
int main() {
const char* config = "inst_name = \"test\"\nnetwork = \"test_network\"\n";
int ret;
// 调用 parse_config
ret = parse_config(config);
if (ret != 0) {
const char* err = NULL;
get_error_msg(&err);
if (err) {
printf("parse_config error: %s\n", err);
free_string(err);
}
return 1;
}
printf("parse_config success\n");
// 调用 run_network_instance
ret = run_network_instance(config);
if (ret != 0) {
const char* err = NULL;
get_error_msg(&err);
if (err) {
printf("run_network_instance error: %s\n", err);
free_string(err);
}
return 1;
}
printf("run_network_instance success\n");
// 周期性调用 collect_network_infos 并打印
const size_t max_infos = 8;
KeyValuePair* infos = (KeyValuePair*)malloc(sizeof(KeyValuePair) * max_infos);
if (!infos) {
fprintf(stderr, "malloc failed\n");
return 1;
}
for (int i = 0; i < 5; ++i) { // 循环5次作为示例
memset(infos, 0, sizeof(KeyValuePair) * max_infos);
int count = collect_network_infos(infos, max_infos);
if (count < 0) {
const char* err = NULL;
get_error_msg(&err);
if (err) {
printf("collect_network_infos error: %s\n", err);
free_string(err);
}
break;
}
printf("collect_network_infos: %d instance(s)\n", count);
for (int j = 0; j < count; ++j) {
printf(" [%d] key: %s\n value: %s\n", j, infos[j].key, infos[j].value);
free_string(infos[j].key);
free_string(infos[j].value);
}
sleep(1);
}
free(infos);
return 0;
}
@@ -0,0 +1,138 @@
# 1. Go FFI Demo
This demo wraps EasyTier FFI data-plane TCP as Go `net.Conn` and `net.Listener`.
It can connect to an SSH server through EasyTier and read its banner, or accept a
TCP connection from another EasyTier peer and run a small ping/pong exchange.
The async op-handle wrapper is in `easytier_async.go`; the original synchronous
wrapper stays in `easytier.go`.
## 1.1. Build the FFI library
Run from the repository root:
```sh
cargo build -p easytier-ffi --features ffi-dataplane
```
The demo loads the debug library by default:
```text
target/debug/libeasytier_ffi.so
```
To use another library path, export `EASYTIER_FFI_LIB=/path/to/libeasytier_ffi.so`.
## 1.2. Configure the EasyTier config
`EASYTIER_FFI_CONFIG` is a string of the EasyTier config in TOML format which is passed to the FFI library. For example:
```sh
export EASYTIER_FFI_CONFIG='instance_name = "default"
ipv4 = "10.0.0.1"
[network_identity]
network_name = "testnet"
network_secret = "mysecret"
[flags]
no_tun = true # disable tun device to avoid permission issues.
bind_device = false # allow loopback peers in local examples.
[[peer]]
uri = "tcp://123.123.123.123:11010"
'
```
You should configure with your own real values.
Set the local instance name and a SSH server target to connect through EasyTier:
```sh
export EASYTIER_FFI_INSTANCE=default
export EASYTIER_FFI_TARGET=10.0.0.2:22
```
To run the TCP listen integration test in the same `go test` process as the SSH
test, use a separate instance name and config:
```sh
export EASYTIER_FFI_LISTEN_CONFIG='instance_name = "listener"
ipv4 = "10.0.0.3"
[network_identity]
network_name = "testnet"
network_secret = "mysecret"
[flags]
no_tun = true
bind_device = false
[[peer]]
uri = "tcp://123.123.123.123:11010"
'
export EASYTIER_FFI_LISTEN_INSTANCE=listener
export EASYTIER_FFI_LISTEN_PORT=12345
```
## 1.3. Run the demo
`goffi` is built without cgo on Linux, so run the tests with `CGO_ENABLED=0`:
```sh
cd easytier-contrib/easytier-ffi/examples/go
CGO_ENABLED=0 go test -v ./...
```
The synchronous tests use the environment variables above. The async Go tests
are self-contained: they start two local EasyTier instances in the same test
process with `no_tun = true` and `bind_device = false`, then run TCP and UDP
ping/pong over the async data-plane API.
The synchronous wrapper also exposes `CallJSONRPC(service, method, domain,
payload)` for non-lifecycle EasyTier RPCs. For example,
`CallJSONRPC("api.logger.LoggerRpcService", "get_logger_config", "", "{}")`
returns the logger config as protobuf JSON. Instance lifecycle management RPCs
are intentionally filtered; use the dedicated FFI APIs for starting and
stopping instances.
To run only the async tests:
```sh
cd easytier-contrib/easytier-ffi/examples/go
CGO_ENABLED=0 go test -run 'TestAsync' -v ./...
```
When the SSH integration environment variables are set, expected synchronous
test output includes an SSH banner similar to:
```text
attempt 1: got banner "SSH-2.0-..."
PASS
```
For `TestTCPListenIntegration`, connect from another EasyTier peer to the local
EasyTier IPv4 address and `EASYTIER_FFI_LISTEN_PORT`, send `ping`, and expect
`pong` in response.
The async test output should include local TCP bind/connect log lines and finish
with `PASS` without any extra environment variables.
## 1.4. C async example
The C async example is kept separate from the basic C example:
```sh
cargo build -p easytier-ffi --features ffi-dataplane
cc -Wall -Wextra -pedantic \
../example_data_plane_async.c \
-L ../../../../target/debug -leasytier_ffi \
-Wl,-rpath,../../../../target/debug \
-o /tmp/easytier_data_plane_async
/tmp/easytier_data_plane_async
```
Without environment variables it prints usage and exits successfully. With
`EASYTIER_FFI_CONFIG`, `EASYTIER_FFI_INSTANCE`, and one of
`EASYTIER_FFI_TARGET`, `EASYTIER_FFI_LISTEN_PORT`, or `EASYTIER_FFI_UDP_TARGET`,
it runs the corresponding async data-plane flow.
@@ -0,0 +1,593 @@
package easytierffi
import (
"context"
"errors"
"fmt"
"io"
"net"
"os"
"runtime"
"strconv"
"strings"
"sync/atomic"
"time"
"unsafe"
"github.com/go-webgpu/goffi/ffi"
"github.com/go-webgpu/goffi/types"
)
const defaultTimeout = 30 * time.Second
type Native struct {
lib unsafe.Pointer
runNetworkInstance symCall
callJSONRPC symCall
getErrorMsg symCall
freeString symCall
tcpConnect symCall
tcpBind symCall
tcpAccept symCall
tcpRead symCall
tcpWrite symCall
tcpClose symCall
tcpListenerClose symCall
}
type Conn struct {
native *Native
handle uint64
local net.Addr
remote net.Addr
closed atomic.Bool
rd atomicDeadline
wd atomicDeadline
}
type Listener struct {
native *Native
handle uint64
addr net.Addr
closed atomic.Bool
}
type symCall struct {
fn unsafe.Pointer
cif types.CallInterface
}
type atomicDeadline struct{ v atomic.Int64 }
type timeoutError string
func Open(path string) (*Native, error) {
lib, err := ffi.LoadLibrary(path)
if err != nil {
return nil, err
}
n := &Native{lib: lib}
if err := n.bind(); err != nil {
ffi.FreeLibrary(lib)
return nil, err
}
return n, nil
}
func (n *Native) Close() error {
if n.lib == nil {
return nil
}
ffi.FreeLibrary(n.lib)
n.lib = nil
return nil
}
func (n *Native) RunNetworkInstance(config string) error {
defer pinErrorThread()()
cfg := cString(config)
cfgPtr := unsafe.Pointer(&cfg[0])
var ret int32
err := n.runNetworkInstance.call(unsafe.Pointer(&ret), unsafe.Pointer(&cfgPtr))
runtime.KeepAlive(cfg)
if err != nil {
return err
}
if ret != 0 {
return n.lastError()
}
return nil
}
func (n *Native) CallJSONRPC(serviceName, methodName, domainName, payloadJSON string) (string, error) {
defer pinErrorThread()()
service := cString(serviceName)
method := cString(methodName)
payload := cString(payloadJSON)
servicePtr := unsafe.Pointer(&service[0])
methodPtr := unsafe.Pointer(&method[0])
payloadPtr := unsafe.Pointer(&payload[0])
var domain []byte
var domainPtr unsafe.Pointer
if domainName != "" {
domain = cString(domainName)
domainPtr = unsafe.Pointer(&domain[0])
}
var response unsafe.Pointer
responseArg := unsafe.Pointer(&response)
var ret int32
err := n.callJSONRPC.call(
unsafe.Pointer(&ret),
unsafe.Pointer(&servicePtr),
unsafe.Pointer(&methodPtr),
unsafe.Pointer(&domainPtr),
unsafe.Pointer(&payloadPtr),
unsafe.Pointer(&responseArg),
)
runtime.KeepAlive(service)
runtime.KeepAlive(method)
runtime.KeepAlive(domain)
runtime.KeepAlive(payload)
if err != nil {
return "", err
}
if ret != 0 {
return "", n.lastError()
}
if response == nil {
return "", errors.New("easytier ffi JSON RPC returned nil response")
}
defer func() { _ = n.freeCString(response) }()
return readCString(response), nil
}
func (n *Native) DialContext(ctx context.Context, instance, network, address string) (net.Conn, error) {
if network != "tcp" && network != "tcp4" && network != "tcp6" {
return nil, net.UnknownNetworkError(network)
}
ip, port, err := parseIPPort(address)
if err != nil {
return nil, err
}
timeout := defaultTimeout
if deadline, ok := ctx.Deadline(); ok {
timeout = time.Until(deadline)
}
if timeout <= 0 {
return nil, context.DeadlineExceeded
}
if err := ctx.Err(); err != nil {
return nil, err
}
handle, local, err := n.tcpConnectTo(instance, ip.String(), uint16(port), timeout)
if err != nil {
return nil, err
}
return &Conn{native: n, handle: handle, local: local, remote: &net.TCPAddr{IP: ip, Port: port}}, nil
}
func (n *Native) ListenContext(ctx context.Context, instance, network, address string) (net.Listener, error) {
if network != "tcp" && network != "tcp4" && network != "tcp6" {
return nil, net.UnknownNetworkError(network)
}
port, err := parseListenPort(address)
if err != nil {
return nil, err
}
timeout := defaultTimeout
if deadline, ok := ctx.Deadline(); ok {
timeout = time.Until(deadline)
}
if timeout <= 0 {
return nil, context.DeadlineExceeded
}
if err := ctx.Err(); err != nil {
return nil, err
}
handle, local, err := n.tcpBindTo(instance, uint16(port), timeout)
if err != nil {
return nil, err
}
return &Listener{native: n, handle: handle, addr: local}, nil
}
func (c *Conn) Read(b []byte) (int, error) {
if c.closed.Load() {
return 0, net.ErrClosed
}
n, err := c.native.tcpReadFrom(c.handle, b, c.rd.timeout(defaultTimeout))
if err != nil {
return 0, opError("read", c.remote, err)
}
if n == 0 {
return 0, io.EOF
}
return n, nil
}
func (c *Conn) Write(b []byte) (int, error) {
if c.closed.Load() {
return 0, net.ErrClosed
}
n, err := c.native.tcpWriteTo(c.handle, b, c.wd.timeout(defaultTimeout))
if err != nil {
return 0, opError("write", c.remote, err)
}
return n, nil
}
func (c *Conn) Close() error {
if !c.closed.CompareAndSwap(false, true) {
return net.ErrClosed
}
return c.native.tcpCloseHandle(c.handle)
}
func (c *Conn) LocalAddr() net.Addr { return c.local }
func (c *Conn) RemoteAddr() net.Addr { return c.remote }
func (c *Conn) SetDeadline(t time.Time) error { c.rd.set(t); c.wd.set(t); return nil }
func (c *Conn) SetReadDeadline(t time.Time) error { c.rd.set(t); return nil }
func (c *Conn) SetWriteDeadline(t time.Time) error { c.wd.set(t); return nil }
func (l *Listener) Accept() (net.Conn, error) {
if l.closed.Load() {
return nil, net.ErrClosed
}
for {
handle, local, peer, err := l.native.tcpAcceptFrom(l.handle, defaultTimeout)
if err == nil {
return &Conn{native: l.native, handle: handle, local: local, remote: peer}, nil
}
if l.closed.Load() {
return nil, net.ErrClosed
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
continue
}
return nil, opError("accept", l.addr, err)
}
}
func (l *Listener) Close() error {
if !l.closed.CompareAndSwap(false, true) {
return net.ErrClosed
}
return l.native.tcpListenerCloseHandle(l.handle)
}
func (l *Listener) Addr() net.Addr { return l.addr }
func (n *Native) bind() error {
return errors.Join(
n.bindSym(&n.runNetworkInstance, "run_network_instance", types.SInt32TypeDescriptor, types.PointerTypeDescriptor),
n.bindSym(&n.callJSONRPC, "call_json_rpc", types.SInt32TypeDescriptor, types.PointerTypeDescriptor, types.PointerTypeDescriptor, types.PointerTypeDescriptor, types.PointerTypeDescriptor, types.PointerTypeDescriptor),
n.bindSym(&n.getErrorMsg, "get_error_msg", types.VoidTypeDescriptor, types.PointerTypeDescriptor),
n.bindSym(&n.freeString, "free_string", types.VoidTypeDescriptor, types.PointerTypeDescriptor),
n.bindSym(&n.tcpConnect, "data_plane_tcp_connect", types.UInt64TypeDescriptor, types.PointerTypeDescriptor, types.PointerTypeDescriptor, types.UInt16TypeDescriptor, types.UInt64TypeDescriptor, types.PointerTypeDescriptor, types.PointerTypeDescriptor),
n.bindSym(&n.tcpBind, "data_plane_tcp_bind", types.UInt64TypeDescriptor, types.PointerTypeDescriptor, types.UInt16TypeDescriptor, types.UInt64TypeDescriptor, types.PointerTypeDescriptor, types.PointerTypeDescriptor),
n.bindSym(&n.tcpAccept, "data_plane_tcp_accept", types.UInt64TypeDescriptor, types.UInt64TypeDescriptor, types.UInt64TypeDescriptor, types.PointerTypeDescriptor, types.PointerTypeDescriptor, types.PointerTypeDescriptor, types.PointerTypeDescriptor),
n.bindSym(&n.tcpRead, "data_plane_tcp_read", types.SInt32TypeDescriptor, types.UInt64TypeDescriptor, types.PointerTypeDescriptor, types.UInt32TypeDescriptor, types.UInt64TypeDescriptor),
n.bindSym(&n.tcpWrite, "data_plane_tcp_write", types.SInt32TypeDescriptor, types.UInt64TypeDescriptor, types.PointerTypeDescriptor, types.UInt32TypeDescriptor, types.UInt64TypeDescriptor),
n.bindSym(&n.tcpClose, "data_plane_tcp_close", types.SInt32TypeDescriptor, types.UInt64TypeDescriptor),
n.bindSym(&n.tcpListenerClose, "data_plane_tcp_listener_close", types.SInt32TypeDescriptor, types.UInt64TypeDescriptor),
)
}
func (n *Native) bindSym(dst *symCall, name string, ret *types.TypeDescriptor, args ...*types.TypeDescriptor) error {
sym, err := ffi.GetSymbol(n.lib, name)
if err != nil {
return err
}
if err := ffi.PrepareCallInterface(&dst.cif, types.DefaultCall, ret, args); err != nil {
return err
}
dst.fn = sym
return nil
}
func (s *symCall) call(ret unsafe.Pointer, args ...unsafe.Pointer) error {
// `ffi.CallFunction` and libffi `ffi_call` are safe to invoke concurrently
// because `cif` is prepared once during binding and only read afterwards.
return ffi.CallFunction(&s.cif, s.fn, ret, args)
}
func (n *Native) tcpConnectTo(instance, ip string, port uint16, timeout time.Duration) (uint64, *net.TCPAddr, error) {
defer pinErrorThread()()
inst := cString(instance)
dst := cString(ip)
instPtr := unsafe.Pointer(&inst[0])
dstPtr := unsafe.Pointer(&dst[0])
timeoutMS := uint64(timeout / time.Millisecond)
var handle uint64
var outIP unsafe.Pointer
outIPArg := unsafe.Pointer(&outIP)
var outPort uint16
outPortArg := unsafe.Pointer(&outPort)
err := n.tcpConnect.call(
unsafe.Pointer(&handle),
unsafe.Pointer(&instPtr),
unsafe.Pointer(&dstPtr),
unsafe.Pointer(&port),
unsafe.Pointer(&timeoutMS),
unsafe.Pointer(&outIPArg),
unsafe.Pointer(&outPortArg),
)
runtime.KeepAlive(inst)
runtime.KeepAlive(dst)
if err != nil {
return 0, nil, err
}
if handle == 0 {
return 0, nil, n.lastError()
}
return handle, n.takeTCPAddr(outIP, outPort), nil
}
func (n *Native) tcpBindTo(instance string, port uint16, timeout time.Duration) (uint64, *net.TCPAddr, error) {
defer pinErrorThread()()
inst := cString(instance)
instPtr := unsafe.Pointer(&inst[0])
timeoutMS := uint64(timeout / time.Millisecond)
var handle uint64
var outIP unsafe.Pointer
outIPArg := unsafe.Pointer(&outIP)
var outPort uint16
outPortArg := unsafe.Pointer(&outPort)
err := n.tcpBind.call(
unsafe.Pointer(&handle),
unsafe.Pointer(&instPtr),
unsafe.Pointer(&port),
unsafe.Pointer(&timeoutMS),
unsafe.Pointer(&outIPArg),
unsafe.Pointer(&outPortArg),
)
runtime.KeepAlive(inst)
if err != nil {
return 0, nil, err
}
if handle == 0 {
return 0, nil, n.lastError()
}
return handle, n.takeTCPAddr(outIP, outPort), nil
}
func (n *Native) tcpAcceptFrom(handle uint64, timeout time.Duration) (uint64, *net.TCPAddr, *net.TCPAddr, error) {
defer pinErrorThread()()
timeoutMS := uint64(timeout / time.Millisecond)
var stream uint64
var outLocalIP unsafe.Pointer
outLocalIPArg := unsafe.Pointer(&outLocalIP)
var outLocalPort uint16
outLocalPortArg := unsafe.Pointer(&outLocalPort)
var outPeerIP unsafe.Pointer
outPeerIPArg := unsafe.Pointer(&outPeerIP)
var outPeerPort uint16
outPeerPortArg := unsafe.Pointer(&outPeerPort)
err := n.tcpAccept.call(
unsafe.Pointer(&stream),
unsafe.Pointer(&handle),
unsafe.Pointer(&timeoutMS),
unsafe.Pointer(&outLocalIPArg),
unsafe.Pointer(&outLocalPortArg),
unsafe.Pointer(&outPeerIPArg),
unsafe.Pointer(&outPeerPortArg),
)
if err != nil {
return 0, nil, nil, err
}
if stream == 0 {
return 0, nil, nil, n.lastError()
}
return stream, n.takeTCPAddr(outLocalIP, outLocalPort), n.takeTCPAddr(outPeerIP, outPeerPort), nil
}
func (n *Native) tcpReadFrom(handle uint64, buf []byte, timeout time.Duration) (int, error) {
if len(buf) == 0 {
return 0, nil
}
defer pinErrorThread()()
var ret int32
bufPtr := unsafe.Pointer(&buf[0])
length := uint32(len(buf))
timeoutMS := uint64(timeout / time.Millisecond)
err := n.tcpRead.call(unsafe.Pointer(&ret), unsafe.Pointer(&handle), unsafe.Pointer(&bufPtr), unsafe.Pointer(&length), unsafe.Pointer(&timeoutMS))
runtime.KeepAlive(buf)
if err != nil {
return 0, err
}
if ret < 0 {
return 0, n.lastError()
}
return int(ret), nil
}
func (n *Native) tcpWriteTo(handle uint64, buf []byte, timeout time.Duration) (int, error) {
if len(buf) == 0 {
return 0, nil
}
defer pinErrorThread()()
var ret int32
bufPtr := unsafe.Pointer(&buf[0])
length := uint32(len(buf))
timeoutMS := uint64(timeout / time.Millisecond)
err := n.tcpWrite.call(unsafe.Pointer(&ret), unsafe.Pointer(&handle), unsafe.Pointer(&bufPtr), unsafe.Pointer(&length), unsafe.Pointer(&timeoutMS))
runtime.KeepAlive(buf)
if err != nil {
return 0, err
}
if ret < 0 {
return 0, n.lastError()
}
return int(ret), nil
}
func (n *Native) tcpCloseHandle(handle uint64) error {
defer pinErrorThread()()
var ret int32
if err := n.tcpClose.call(unsafe.Pointer(&ret), unsafe.Pointer(&handle)); err != nil {
return err
}
if ret != 0 {
return n.lastError()
}
return nil
}
func (n *Native) tcpListenerCloseHandle(handle uint64) error {
defer pinErrorThread()()
var ret int32
if err := n.tcpListenerClose.call(unsafe.Pointer(&ret), unsafe.Pointer(&handle)); err != nil {
return err
}
if ret != 0 {
return n.lastError()
}
return nil
}
// pinErrorThread ties an FFI op to the get_error_msg that reads its result: the
// Rust side stores the last error in a thread-local, so the goroutine must not
// migrate to another OS thread between the two calls. Use as `defer pinErrorThread()()`
// at the start of any wrapper that reports failures through lastError.
func pinErrorThread() func() {
runtime.LockOSThread()
return runtime.UnlockOSThread
}
func (n *Native) lastError() error {
var out unsafe.Pointer
outArg := unsafe.Pointer(&out)
if err := n.getErrorMsg.call(nil, unsafe.Pointer(&outArg)); err != nil {
return err
}
if out == nil {
return errors.New("easytier ffi call failed")
}
msg := readCString(out)
_ = n.freeCString(out)
if strings.Contains(msg, "timed out") {
return timeoutError(msg)
}
return errors.New(msg)
}
func (n *Native) freeCString(ptr unsafe.Pointer) error {
if ptr == nil {
return nil
}
return n.freeString.call(nil, unsafe.Pointer(&ptr))
}
func (n *Native) takeTCPAddr(ipPtr unsafe.Pointer, port uint16) *net.TCPAddr {
if ipPtr == nil {
return nil
}
ip := net.ParseIP(readCString(ipPtr))
_ = n.freeCString(ipPtr)
return &net.TCPAddr{IP: ip, Port: int(port)}
}
func (d *atomicDeadline) set(t time.Time) {
if t.IsZero() {
d.v.Store(0)
return
}
d.v.Store(t.UnixNano())
}
func (d *atomicDeadline) timeout(fallback time.Duration) time.Duration {
ns := d.v.Load()
if ns == 0 {
return fallback
}
remaining := time.Until(time.Unix(0, ns))
if remaining <= 0 {
return time.Millisecond
}
return remaining
}
func (e timeoutError) Error() string { return string(e) }
func (e timeoutError) Timeout() bool { return true }
func (e timeoutError) Temporary() bool { return true }
func opError(op string, addr net.Addr, err error) error {
return &net.OpError{Op: op, Net: "easytier", Addr: addr, Err: err}
}
func parseIPPort(address string) (net.IP, int, error) {
host, portStr, err := net.SplitHostPort(address)
if err != nil {
return nil, 0, err
}
ip := net.ParseIP(host)
if ip == nil {
return nil, 0, fmt.Errorf("easytier ffi requires an IP address, got %q", host)
}
port, err := strconv.ParseUint(portStr, 10, 16)
if err != nil {
return nil, 0, err
}
return ip, int(port), nil
}
func parseListenPort(address string) (int, error) {
host, portStr, err := net.SplitHostPort(address)
if err != nil {
return 0, err
}
if host != "" {
ip := net.ParseIP(host)
if ip == nil {
return 0, fmt.Errorf("easytier ffi requires an IP address, got %q", host)
}
if !ip.IsUnspecified() {
return 0, fmt.Errorf("easytier ffi listen address must be unspecified, got %q", host)
}
}
port, err := strconv.ParseUint(portStr, 10, 16)
if err != nil {
return 0, err
}
return int(port), nil
}
func cString(s string) []byte {
if strings.ContainsRune(s, 0) {
panic("easytier ffi string contains NUL")
}
return append([]byte(s), 0)
}
func readCString(ptr unsafe.Pointer) string {
if ptr == nil {
return ""
}
var b []byte
for p := uintptr(ptr); ; p++ {
c := *(*byte)(unsafe.Pointer(p))
if c == 0 {
return string(b)
}
b = append(b, c)
}
}
func defaultLibraryPath() string {
if p := os.Getenv("EASYTIER_FFI_LIB"); p != "" {
return p
}
switch runtime.GOOS {
case "darwin":
return "../../../../target/debug/libeasytier_ffi.dylib"
case "windows":
return "..\\..\\..\\..\\target\\debug\\easytier_ffi.dll"
default:
return "../../../../target/debug/libeasytier_ffi.so"
}
}
var _ net.Conn = (*Conn)(nil)
var _ net.Listener = (*Listener)(nil)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,360 @@
package easytierffi
import (
"context"
"fmt"
"io"
"net"
"os"
"strconv"
"testing"
"time"
)
const asyncLocalTestTimeout = 120 * time.Second
func TestAsyncSymbolBinding(t *testing.T) {
n := openAsyncForTest(t)
status, err := n.opWaitStatus(0, 0)
if err != nil {
t.Fatal(err)
}
if status != dataPlaneOpInvalid {
t.Fatalf("expected invalid status for op 0, got %d", status)
}
}
func TestAsyncLocalTwoNodeTCPAndUDP(t *testing.T) {
n := openAsyncForTest(t)
topology := startLocalAsyncTopology(t, n)
ctx, cancel := context.WithTimeout(context.Background(), asyncLocalTestTimeout)
defer cancel()
runAsyncTCPPingPong(t, ctx, n, topology)
runAsyncUDPPingPong(t, ctx, n, topology)
}
type localAsyncTopology struct {
dialerInstance string
listenerInstance string
listenerIP string
}
func openAsyncForTest(t *testing.T) *AsyncNative {
t.Helper()
libraryPath := defaultLibraryPath()
if _, err := os.Stat(libraryPath); err != nil {
if os.IsNotExist(err) {
t.Skipf("build easytier-ffi with ffi-dataplane before running async tests: %v", err)
}
t.Fatalf("stat async ffi library: %v", err)
}
n, err := OpenAsync(libraryPath)
if err != nil {
t.Fatalf("open async ffi library: %v", err)
}
t.Cleanup(func() {
if err := n.Close(); err != nil {
t.Errorf("close async native: %v", err)
}
})
return n
}
func startLocalAsyncTopology(t *testing.T, n *AsyncNative) localAsyncTopology {
t.Helper()
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
networkName := "ffi-async-" + suffix
networkSecret := "ffi-async-secret-" + suffix
listenerInstance := "ffi-async-listener-" + suffix
dialerInstance := "ffi-async-dialer-" + suffix
listenerIP := "10.251.1.2"
dialerIP := "10.251.1.1"
listenerPort := freeLocalTCPPort(t)
listenerEndpoint := fmt.Sprintf("tcp://127.0.0.1:%d", listenerPort)
t.Cleanup(func() {
if err := n.deleteNetworkInstances([]string{dialerInstance, listenerInstance}); err != nil {
t.Errorf("cleanup async test EasyTier instances: %v", err)
}
})
listenerConfig := localAsyncConfig(
listenerInstance,
listenerIP,
networkName,
networkSecret,
[]string{listenerEndpoint},
nil,
)
dialerConfig := localAsyncConfig(
dialerInstance,
dialerIP,
networkName,
networkSecret,
nil,
[]string{listenerEndpoint},
)
if err := n.RunNetworkInstance(listenerConfig); err != nil {
t.Fatalf("start listener instance: %v", err)
}
if err := n.RunNetworkInstance(dialerConfig); err != nil {
t.Fatalf("start dialer instance: %v", err)
}
return localAsyncTopology{
dialerInstance: dialerInstance,
listenerInstance: listenerInstance,
listenerIP: listenerIP,
}
}
func localAsyncConfig(instance, ipv4, networkName, networkSecret string, listeners, peers []string) string {
config := fmt.Sprintf(`instance_name = %s
ipv4 = %s
listeners = %s
[network_identity]
network_name = %s
network_secret = %s
[flags]
no_tun = true
bind_device = false
`,
strconv.Quote(instance),
strconv.Quote(ipv4),
tomlStringList(listeners),
strconv.Quote(networkName),
strconv.Quote(networkSecret),
)
for _, peer := range peers {
config += fmt.Sprintf("\n[[peer]]\nuri = %s\n", strconv.Quote(peer))
}
return config
}
func tomlStringList(values []string) string {
if len(values) == 0 {
return "[]"
}
out := "["
for i, value := range values {
if i > 0 {
out += ", "
}
out += strconv.Quote(value)
}
return out + "]"
}
func freeLocalTCPPort(t *testing.T) int {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("allocate local tcp port: %v", err)
}
defer listener.Close()
return listener.Addr().(*net.TCPAddr).Port
}
func runAsyncTCPPingPong(t *testing.T, ctx context.Context, n *AsyncNative, topology localAsyncTopology) {
t.Helper()
listener, listenerAddr := eventuallyTCPListen(t, ctx, n, topology.listenerInstance)
tcpCtx, cancel := context.WithCancel(ctx)
accepted := make(chan error, 1)
defer waitForAsyncHelper(t, accepted, "tcp accept helper")
defer cancel()
defer listener.Close()
go func() {
conn, err := listener.Accept()
if err != nil {
accepted <- fmt.Errorf("accept tcp stream: %w", err)
return
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(30 * time.Second))
payload := make([]byte, len("ping"))
if _, err := io.ReadFull(conn, payload); err != nil {
accepted <- fmt.Errorf("read tcp ping: %w", err)
return
}
if string(payload) != "ping" {
accepted <- fmt.Errorf("expected tcp ping, got %q", string(payload))
return
}
if _, err := conn.Write([]byte("pong")); err != nil {
accepted <- fmt.Errorf("write tcp pong: %w", err)
return
}
accepted <- nil
}()
conn, err := eventuallyTCPDial(t, tcpCtx, n, topology.dialerInstance, topology.listenerIP, listenerAddr.Port)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(30 * time.Second))
if _, err := conn.Write([]byte("ping")); err != nil {
t.Fatalf("write tcp ping: %v", err)
}
payload := make([]byte, len("pong"))
if _, err := io.ReadFull(conn, payload); err != nil {
t.Fatalf("read tcp pong: %v", err)
}
if string(payload) != "pong" {
t.Fatalf("expected tcp pong, got %q", string(payload))
}
}
func eventuallyTCPListen(t *testing.T, ctx context.Context, n *AsyncNative, instance string) (net.Listener, *net.TCPAddr) {
t.Helper()
var lastErr error
for attempt := 1; ctx.Err() == nil; attempt++ {
attemptCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
listener, err := n.ListenContext(attemptCtx, instance, "tcp", "0.0.0.0:0")
cancel()
if err == nil {
addr := listener.Addr().(*net.TCPAddr)
t.Logf("async tcp bind succeeded on attempt %d at %s", attempt, addr)
return listener, addr
}
lastErr = err
t.Logf("attempt %d: async tcp bind failed: %v", attempt, err)
waitForRetry(ctx, 500*time.Millisecond)
}
t.Fatalf("async tcp bind never succeeded: %v", lastErr)
panic("unreachable")
}
func eventuallyTCPDial(t *testing.T, ctx context.Context, n *AsyncNative, instance, ip string, port int) (net.Conn, error) {
t.Helper()
address := net.JoinHostPort(ip, strconv.Itoa(port))
var lastErr error
for attempt := 1; ctx.Err() == nil; attempt++ {
attemptCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
conn, err := n.DialContext(attemptCtx, instance, "tcp", address)
cancel()
if err == nil {
t.Logf("async tcp connect succeeded on attempt %d to %s", attempt, address)
return conn, nil
}
lastErr = err
t.Logf("attempt %d: async tcp connect failed: %v", attempt, err)
waitForRetry(ctx, 500*time.Millisecond)
}
return nil, fmt.Errorf("async tcp connect never succeeded: %w", lastErr)
}
func runAsyncUDPPingPong(t *testing.T, ctx context.Context, n *AsyncNative, topology localAsyncTopology) {
t.Helper()
dialerSocket, err := n.UDPBindContext(ctx, topology.dialerInstance, 0)
if err != nil {
t.Fatalf("bind dialer udp socket: %v", err)
}
listenerSocket, err := n.UDPBindContext(ctx, topology.listenerInstance, 0)
if err != nil {
t.Fatalf("bind listener udp socket: %v", err)
}
udpCtx, cancel := context.WithCancel(ctx)
warmupDone := make(chan error, 1)
received := make(chan error, 1)
defer waitForAsyncHelper(t, received, "udp receive helper")
defer cancel()
defer listenerSocket.Close()
defer dialerSocket.Close()
go func() {
if _, err := listenerSocket.SendTo(udpCtx, []byte("warmup"), dialerSocket.LocalAddr()); err != nil {
err = fmt.Errorf("send udp warmup: %w", err)
warmupDone <- err
received <- err
return
}
warmupDone <- nil
payload, from, err := listenerSocket.RecvFrom(udpCtx, 512)
if err != nil {
received <- fmt.Errorf("recv udp ping: %w", err)
return
}
if string(payload) != "ping" {
received <- fmt.Errorf("expected udp ping, got %q", string(payload))
return
}
if _, err := listenerSocket.SendTo(udpCtx, []byte("pong"), from); err != nil {
received <- fmt.Errorf("send udp pong: %w", err)
return
}
received <- nil
}()
select {
case err := <-warmupDone:
if err != nil {
t.Fatal(err)
}
case <-udpCtx.Done():
t.Fatal(udpCtx.Err())
}
target := &net.UDPAddr{IP: net.ParseIP(topology.listenerIP), Port: listenerSocket.LocalAddr().Port}
if _, err := dialerSocket.SendTo(udpCtx, []byte("ping"), target); err != nil {
t.Fatalf("send udp ping: %v", err)
}
for {
payload, from, err := dialerSocket.RecvFrom(udpCtx, 512)
if err != nil {
t.Fatalf("recv udp pong: %v", err)
}
if string(payload) == "pong" {
if !from.IP.Equal(target.IP) || from.Port != target.Port {
t.Fatalf("expected udp pong from %s, got %s", target, from)
}
break
}
t.Logf("skipping udp datagram from %s: %q", from, string(payload))
}
}
func waitForAsyncHelper(t *testing.T, done <-chan error, name string) {
t.Helper()
select {
case err := <-done:
if err != nil {
t.Errorf("%s: %v", name, err)
}
case <-time.After(10 * time.Second):
t.Errorf("%s did not stop", name)
}
}
func waitForRetry(ctx context.Context, delay time.Duration) {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-timer.C:
case <-ctx.Done():
}
}
@@ -0,0 +1,140 @@
package easytierffi
import (
"context"
"fmt"
"io"
"net"
"os"
"strconv"
"strings"
"testing"
"time"
)
func TestSSHIntegration(t *testing.T) {
config := os.Getenv("EASYTIER_FFI_CONFIG")
instance := os.Getenv("EASYTIER_FFI_INSTANCE")
target := os.Getenv("EASYTIER_FFI_TARGET")
if config == "" || instance == "" || target == "" {
t.Skip("set EASYTIER_FFI_CONFIG, EASYTIER_FFI_INSTANCE and EASYTIER_FFI_TARGET to run integration test")
}
n, err := Open(defaultLibraryPath())
if err != nil {
t.Fatal(err)
}
defer n.Close()
if err := n.RunNetworkInstance(config); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
var lastErr error
for attempt := 1; ctx.Err() == nil; attempt++ {
conn, err := n.DialContext(ctx, instance, "tcp", target)
if err != nil {
lastErr = err
t.Logf("attempt %d: dial failed: %v", attempt, err)
time.Sleep(3 * time.Second)
continue
}
_ = conn.SetReadDeadline(time.Now().Add(10 * time.Second))
buf := make([]byte, 128)
nn, err := conn.Read(buf)
_ = conn.Close()
if err != nil {
lastErr = err
t.Logf("attempt %d: read failed: %v", attempt, err)
time.Sleep(3 * time.Second)
continue
}
banner := string(buf[:nn])
if !strings.HasPrefix(banner, "SSH-") {
t.Fatalf("attempt %d: expected SSH banner, got %q", attempt, banner)
}
t.Logf("attempt %d: got banner %q", attempt, strings.TrimRight(banner, "\r\n"))
return
}
t.Fatalf("never got SSH banner, last err: %v", lastErr)
}
func TestTCPListenIntegration(t *testing.T) {
config := os.Getenv("EASYTIER_FFI_LISTEN_CONFIG")
instance := os.Getenv("EASYTIER_FFI_LISTEN_INSTANCE")
listenPort := os.Getenv("EASYTIER_FFI_LISTEN_PORT")
if config == "" || instance == "" || listenPort == "" {
t.Skip("set EASYTIER_FFI_LISTEN_CONFIG, EASYTIER_FFI_LISTEN_INSTANCE and EASYTIER_FFI_LISTEN_PORT to run integration test")
}
port, err := strconv.ParseUint(listenPort, 10, 16)
if err != nil {
t.Fatal(err)
}
n, err := Open(defaultLibraryPath())
if err != nil {
t.Fatal(err)
}
defer n.Close()
if err := n.RunNetworkInstance(config); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
// Data-plane readiness is asynchronous: the instance must finish starting
// before the data plane accepts binds. Retry until ready or ctx expires.
var listener net.Listener
for attempt := 1; ; attempt++ {
listener, err = n.ListenContext(ctx, instance, "tcp", net.JoinHostPort("0.0.0.0", strconv.Itoa(int(port))))
if err == nil {
break
}
if ctx.Err() != nil {
t.Fatalf("bind never succeeded, last err: %v", err)
}
t.Logf("attempt %d: bind failed: %v", attempt, err)
time.Sleep(3 * time.Second)
}
t.Logf("listening on %s; connect from another EasyTier peer and send ping", listener.Addr())
accepted := make(chan error, 1)
go func() {
conn, err := listener.Accept()
if err != nil {
accepted <- err
return
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
buf := make([]byte, 4)
if _, err := io.ReadFull(conn, buf); err != nil {
accepted <- err
return
}
if string(buf) != "ping" {
accepted <- fmt.Errorf("expected %q, got %q", "ping", string(buf))
return
}
_, err = conn.Write([]byte("pong"))
accepted <- err
}()
select {
case err := <-accepted:
_ = listener.Close()
if err != nil {
t.Fatal(err)
}
case <-ctx.Done():
_ = listener.Close()
t.Fatal(ctx.Err())
}
}
@@ -0,0 +1,5 @@
module easytierffi-example
go 1.25
require github.com/go-webgpu/goffi v0.4.1
@@ -0,0 +1,2 @@
github.com/go-webgpu/goffi v0.4.1 h1:2hQH5XXloxTyTtIleYv+Rajlwzp6UOETURhSZ5+zJxU=
github.com/go-webgpu/goffi v0.4.1/go.mod h1:wfoxNsJkU+5RFbV1kNN1kunhc1lFHuJKK3zpgx08/uM=
@@ -0,0 +1,575 @@
use std::{
cell::Cell,
collections::HashSet,
ffi::{CString, c_char, c_int, c_void},
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
};
use easytier::{
common::{
MachineIdOptions,
config::{ConfigLoader as _, TomlConfigLoader},
},
tunnel::TunnelScheme,
web_client::{WebClient, WebClientHooks, run_web_client},
};
use uuid::Uuid;
use crate::{
data_plane::remove_data_plane_handles_by_instance_ids,
error::set_error_msg,
state::{
ASYNC_RUNTIME, INSTANCE_MANAGER, INSTANCE_MUTATION_LOCK, INSTANCE_NAME_ID_MAP,
lock_remote_instance_mutation, remove_instance_name_ids,
},
strings::{c_str_to_string, optional_c_str_to_string},
types::ConfigServerEventCallback,
};
thread_local! {
static IN_CONFIG_SERVER_CALLBACK: Cell<bool> = const { Cell::new(false) };
}
static CONFIG_SERVER_CLIENT: once_cell::sync::Lazy<Mutex<Option<ManagedConfigServerClient>>> =
once_cell::sync::Lazy::new(|| Mutex::new(None));
static CONFIG_SERVER_CLIENT_ACTIVE: once_cell::sync::Lazy<AtomicBool> =
once_cell::sync::Lazy::new(|| AtomicBool::new(false));
static CONFIG_SERVER_CLIENT_STOPPING: once_cell::sync::Lazy<AtomicBool> =
once_cell::sync::Lazy::new(|| AtomicBool::new(false));
static LAST_CONFIG_SERVER_CALLBACK_ERROR: once_cell::sync::Lazy<Mutex<Option<String>>> =
once_cell::sync::Lazy::new(|| Mutex::new(None));
pub(crate) struct ConfigServerCallbackScope;
impl ConfigServerCallbackScope {
pub(crate) fn enter() -> Self {
IN_CONFIG_SERVER_CALLBACK.with(|in_callback| in_callback.set(true));
Self
}
}
impl Drop for ConfigServerCallbackScope {
fn drop(&mut self) {
IN_CONFIG_SERVER_CALLBACK.with(|in_callback| in_callback.set(false));
}
}
pub fn in_config_server_callback() -> bool {
IN_CONFIG_SERVER_CALLBACK.with(Cell::get)
}
fn config_server_machine_id_options(machine_id: String) -> MachineIdOptions {
MachineIdOptions {
explicit_machine_id: Some(machine_id),
state_dir: None,
}
}
pub fn validate_config_server_client_options(
config_server_url_s: &str,
machine_id: &str,
) -> Result<(), String> {
if machine_id.trim().is_empty() {
return Err("machine_id is empty".to_string());
}
let config_server_url = match url::Url::parse(config_server_url_s) {
Ok(url) => url,
Err(_) => format!(
"udp://config-server.easytier.cn:22020/{}",
config_server_url_s
)
.parse()
.map_err(|err| format!("failed to parse config server URL: {}", err))?,
};
TunnelScheme::try_from(&config_server_url).map_err(|_| {
format!(
"unsupported config server scheme: {}",
config_server_url.scheme()
)
})?;
let token = config_server_url
.path_segments()
.and_then(|mut segments| segments.next_back())
.map(|segment| percent_encoding::percent_decode_str(segment).decode_utf8())
.transpose()
.map_err(|err| format!("failed to decode config server token: {}", err))?
.map(|token| token.to_string())
.unwrap_or_default();
if token.is_empty() {
return Err("empty token".to_string());
}
Ok(())
}
struct ManagedConfigServerClient {
client: WebClient,
hooks: Arc<ManagedConfigServerClientHooks>,
}
pub(crate) struct ManagedConfigServerClientHooks {
pub(crate) instance_ids: Mutex<HashSet<Uuid>>,
callback_delivery: Mutex<()>,
stopping: AtomicBool,
callback: ConfigServerEventCallback,
user_data: usize,
}
impl ManagedConfigServerClientHooks {
pub(crate) fn new(callback: ConfigServerEventCallback, user_data: *mut c_void) -> Self {
Self {
instance_ids: Mutex::new(HashSet::new()),
callback_delivery: Mutex::new(()),
stopping: AtomicBool::new(false),
callback,
user_data: user_data as usize,
}
}
#[cfg(test)]
pub(crate) fn tracked_instance_ids(&self) -> Vec<Uuid> {
self.instance_ids
.lock()
.map(|guard| guard.iter().copied().collect())
.unwrap_or_default()
}
fn remove_tracked_instance_ids(&self, ids: &[Uuid]) -> Result<Vec<Uuid>, String> {
let mut guard = self.instance_ids.lock().map_err(|err| err.to_string())?;
Ok(ids
.iter()
.filter_map(|id| guard.remove(id).then_some(*id))
.collect())
}
fn validate_instance_name(&self, inst_name: &str, inst_id: Uuid) -> Result<(), String> {
if let Some(existing_id) = INSTANCE_NAME_ID_MAP.get(inst_name).map(|id| *id)
&& existing_id != inst_id
{
return Err(format!("instance name {} already exists", inst_name));
}
Ok(())
}
fn commit_instance_name(&self, inst_name: String, inst_id: Uuid) -> Result<(), String> {
INSTANCE_NAME_ID_MAP.retain(|_, existing_id| *existing_id != inst_id);
self.validate_instance_name(&inst_name, inst_id)?;
INSTANCE_NAME_ID_MAP.insert(inst_name, inst_id);
Ok(())
}
pub(crate) fn start_stopping(&self) -> Vec<Uuid> {
let _delivery_guard = if in_config_server_callback() {
None
} else {
self.callback_delivery.lock().ok()
};
let mut guard = match self.instance_ids.lock() {
Ok(guard) => guard,
Err(_) => return Vec::new(),
};
self.stopping.store(true, Ordering::Release);
guard.drain().collect()
}
pub(crate) fn note_callback_error(&self, error: String) {
log::warn!("config server event callback failed: {}", error);
if let Ok(mut guard) = LAST_CONFIG_SERVER_CALLBACK_ERROR.lock() {
*guard = Some(error);
}
}
fn emit_event_with_delivery_locked(
&self,
event: &str,
instance_id: Uuid,
) -> Result<(), String> {
if self.stopping.load(Ordering::Acquire) {
return Ok(());
}
let Some(callback) = self.callback else {
return Ok(());
};
let instance_name = INSTANCE_MANAGER
.get_instance_name(&instance_id)
.unwrap_or_default();
let network_name = INSTANCE_MANAGER
.get_network_name(&instance_id)
.unwrap_or_default();
let event_json = serde_json::json!({
"event": event,
"success": true,
"instance_id": instance_id.to_string(),
"instance_name": instance_name,
"network_name": network_name,
"error": null,
})
.to_string();
let event_json = CString::new(event_json).map_err(|err| err.to_string())?;
let _callback_scope = ConfigServerCallbackScope::enter();
unsafe {
callback(event_json.as_ptr(), self.user_data as *mut c_void);
}
Ok(())
}
fn emit_event(&self, event: &str, instance_id: Uuid) -> Result<(), String> {
let _delivery_guard = self
.callback_delivery
.lock()
.map_err(|err| err.to_string())?;
self.emit_event_with_delivery_locked(event, instance_id)
}
fn wait_for_callback_delivery(&self) {
if in_config_server_callback() {
return;
}
if let Ok(guard) = self.callback_delivery.lock() {
drop(guard);
}
}
}
#[async_trait::async_trait]
impl WebClientHooks for ManagedConfigServerClientHooks {
fn manages_remote_config_instances(&self) -> bool {
true
}
async fn pre_run_network_instance(&self, cfg: &TomlConfigLoader) -> Result<(), String> {
if self.stopping.load(Ordering::Acquire) {
return Err("config server client is stopping".to_string());
}
let inst_name = cfg.get_inst_name();
let inst_id = cfg.get_id();
self.validate_instance_name(&inst_name, inst_id)
}
async fn post_run_network_instance(&self, id: &Uuid) -> Result<(), String> {
let _delivery_guard = self
.callback_delivery
.lock()
.map_err(|err| err.to_string())?;
let Some(inst_name) = INSTANCE_MANAGER.get_instance_name(id) else {
if !self.stopping.load(Ordering::Acquire) {
return Err(format!("instance {} not found after start", id));
}
return Ok(());
};
{
let _mutation_guard = INSTANCE_MUTATION_LOCK
.lock()
.map_err(|err| err.to_string())?;
if INSTANCE_MANAGER.get_instance_name(id).is_none() {
if !self.stopping.load(Ordering::Acquire) {
return Err(format!("instance {} not found after start", id));
}
return Ok(());
}
let should_delete = {
let mut guard = self.instance_ids.lock().map_err(|err| err.to_string())?;
if self.stopping.load(Ordering::Acquire) {
true
} else {
guard.insert(*id);
false
}
};
if should_delete {
if let Err(err) = INSTANCE_MANAGER.delete_network_instance(vec![*id]) {
return Err(err.to_string());
}
remove_instance_name_ids(&[*id]);
return Ok(());
}
if self.stopping.load(Ordering::Acquire) {
self.remove_tracked_instance_ids(&[*id])?;
remove_instance_name_ids(&[*id]);
return Ok(());
}
if let Err(err) = self.commit_instance_name(inst_name.clone(), *id) {
self.remove_tracked_instance_ids(&[*id])?;
if let Err(delete_err) = INSTANCE_MANAGER.delete_network_instance(vec![*id]) {
return Err(format!(
"{}; failed to delete duplicate instance: {}",
err, delete_err
));
}
return Err(err);
}
if self.stopping.load(Ordering::Acquire) {
self.remove_tracked_instance_ids(&[*id])?;
remove_instance_name_ids(&[*id]);
return Ok(());
}
if INSTANCE_MANAGER.get_instance_name(id).is_none() {
self.remove_tracked_instance_ids(&[*id])?;
remove_instance_name_ids(&[*id]);
return Err(format!(
"instance {} was removed before post-run completed",
id
));
}
}
remove_data_plane_handles_by_instance_ids(&[*id]);
if let Err(err) = self.emit_event_with_delivery_locked("run_network_instance", *id) {
self.note_callback_error(err);
}
Ok(())
}
async fn post_remove_network_instances(&self, ids: &[Uuid]) -> Result<(), String> {
let removed_ids = {
let _mutation_guard = INSTANCE_MUTATION_LOCK
.lock()
.map_err(|err| err.to_string())?;
let removed_ids = self.remove_tracked_instance_ids(ids)?;
remove_instance_name_ids(ids);
remove_data_plane_handles_by_instance_ids(&removed_ids);
removed_ids
};
for id in removed_ids {
if let Err(err) = self.emit_event("delete_network_instance", id) {
self.note_callback_error(err);
}
}
Ok(())
}
}
pub(crate) fn remove_config_server_tracked_instance_ids(ids: &[Uuid]) {
if ids.is_empty() {
return;
}
if let Ok(guard) = CONFIG_SERVER_CLIENT.lock()
&& let Some(managed) = guard.as_ref()
&& let Err(err) = managed.hooks.remove_tracked_instance_ids(ids)
{
log::warn!("failed to remove config server tracked ids: {}", err);
}
}
pub(crate) fn wait_for_config_server_delivery() {
let hooks = CONFIG_SERVER_CLIENT
.lock()
.ok()
.and_then(|guard| guard.as_ref().map(|managed| managed.hooks.clone()));
if let Some(hooks) = hooks {
hooks.wait_for_callback_delivery();
}
}
pub(crate) fn last_callback_error() -> Option<String> {
LAST_CONFIG_SERVER_CALLBACK_ERROR
.lock()
.ok()
.and_then(|guard| guard.clone())
}
pub(crate) fn clear_last_callback_error() {
if let Ok(mut guard) = LAST_CONFIG_SERVER_CALLBACK_ERROR.lock() {
*guard = None;
}
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn is_config_server_active_or_stopping() -> bool {
CONFIG_SERVER_CLIENT_ACTIVE.load(Ordering::Acquire)
|| CONFIG_SERVER_CLIENT_STOPPING.load(Ordering::Acquire)
}
#[cfg(test)]
pub(crate) fn set_active_for_test(active: bool) {
CONFIG_SERVER_CLIENT_ACTIVE.store(active, Ordering::Release);
}
/// # Safety
/// Start the config server client.
///
/// `config_server_url` must be a valid null-terminated UTF-8 string.
/// `hostname` may be null; if non-null it must be a valid null-terminated UTF-8 string.
/// `machine_id` must be a valid null-terminated UTF-8 string.
/// `event_json` passed to `callback` is valid only during that callback invocation.
pub(crate) unsafe fn start_config_server_client(
config_server_url: *const c_char,
hostname: *const c_char,
machine_id: *const c_char,
secure_mode: bool,
callback: ConfigServerEventCallback,
user_data: *mut c_void,
) -> c_int {
if in_config_server_callback() {
set_error_msg("cannot start config server client from config server callback");
return -1;
}
let config_server_url = match unsafe { c_str_to_string(config_server_url, "config_server_url") }
{
Ok(value) => value,
Err(err) => {
set_error_msg(&err);
return -1;
}
};
let hostname = match unsafe { optional_c_str_to_string(hostname, "hostname") } {
Ok(value) => value,
Err(err) => {
set_error_msg(&err);
return -1;
}
};
let machine_id = match unsafe { c_str_to_string(machine_id, "machine_id") } {
Err(err) => {
set_error_msg(&err);
return -1;
}
Ok(value) => value,
};
if let Err(err) = validate_config_server_client_options(&config_server_url, &machine_id) {
set_error_msg(&err);
return -1;
}
let mut guard = match CONFIG_SERVER_CLIENT.lock() {
Ok(guard) => guard,
Err(err) => {
set_error_msg(&format!("failed to lock config server client: {}", err));
return -1;
}
};
if guard.is_some() {
set_error_msg("config server client already exists");
return -1;
}
if CONFIG_SERVER_CLIENT_STOPPING.load(Ordering::Acquire) {
set_error_msg("config server client is stopping");
return -1;
}
clear_last_callback_error();
#[cfg(feature = "ffi-dataplane")]
let data_plane_usage_guard = match crate::data_plane::lock_for_config_server_start() {
Ok(guard) => guard,
Err(err) => {
set_error_msg(&err);
return -1;
}
};
CONFIG_SERVER_CLIENT_ACTIVE.store(true, Ordering::Release);
#[cfg(feature = "ffi-dataplane")]
drop(data_plane_usage_guard);
let hooks = Arc::new(ManagedConfigServerClientHooks::new(callback, user_data));
let client = match ASYNC_RUNTIME.block_on(run_web_client(
&config_server_url,
config_server_machine_id_options(machine_id),
hostname,
secure_mode,
INSTANCE_MANAGER.clone(),
Some(hooks.clone()),
)) {
Ok(client) => client,
Err(err) => {
CONFIG_SERVER_CLIENT_ACTIVE.store(false, Ordering::Release);
set_error_msg(&format!("failed to start config server client: {}", err));
return -1;
}
};
*guard = Some(ManagedConfigServerClient { client, hooks });
0
}
pub(crate) fn stop_config_server_client() -> c_int {
if in_config_server_callback() {
set_error_msg("cannot stop config server client from config server callback");
return -1;
}
let mut guard = match CONFIG_SERVER_CLIENT.lock() {
Ok(guard) => guard,
Err(err) => {
set_error_msg(&format!("failed to lock config server client: {}", err));
return -1;
}
};
let Some(managed) = guard.as_ref() else {
CONFIG_SERVER_CLIENT_ACTIVE.store(false, Ordering::Release);
return 0;
};
if CONFIG_SERVER_CLIENT_STOPPING.swap(true, Ordering::AcqRel) {
set_error_msg("config server client is stopping");
return -1;
}
let hooks = managed.hooks.clone();
let managed = guard.take().expect("config server client exists");
drop(guard);
let _remote_mutation_guard = lock_remote_instance_mutation();
let tracked_ids = hooks.start_stopping();
drop(managed);
let _mutation_guard = match INSTANCE_MUTATION_LOCK.lock() {
Ok(guard) => guard,
Err(err) => {
hooks.wait_for_callback_delivery();
CONFIG_SERVER_CLIENT_ACTIVE.store(false, Ordering::Release);
CONFIG_SERVER_CLIENT_STOPPING.store(false, Ordering::Release);
set_error_msg(&format!("failed to lock instance mutation: {}", err));
return -1;
}
};
let delete_result = INSTANCE_MANAGER.delete_network_instance(tracked_ids.clone());
if delete_result.is_ok() {
remove_instance_name_ids(&tracked_ids);
remove_data_plane_handles_by_instance_ids(&tracked_ids);
}
drop(_mutation_guard);
hooks.wait_for_callback_delivery();
CONFIG_SERVER_CLIENT_ACTIVE.store(false, Ordering::Release);
CONFIG_SERVER_CLIENT_STOPPING.store(false, Ordering::Release);
if let Err(err) = delete_result {
set_error_msg(&format!(
"failed to delete config server instances: {}",
err
));
return -1;
}
0
}
pub(crate) fn is_config_server_client_connected() -> c_int {
CONFIG_SERVER_CLIENT
.lock()
.ok()
.and_then(|guard| guard.as_ref().map(|managed| managed.client.is_connected()))
.map(i32::from)
.unwrap_or(0)
}
@@ -0,0 +1,928 @@
#[cfg(feature = "ffi-dataplane")]
use std::{
future::Future,
net::{IpAddr, SocketAddr},
sync::{
Arc, RwLock,
atomic::{AtomicU64, Ordering},
},
time::Duration,
};
#[cfg(feature = "ffi-dataplane")]
use dashmap::DashMap;
#[cfg(feature = "ffi-dataplane")]
use easytier::launcher::{DataPlaneTcpListener, DataPlaneTcpStream, DataPlaneUdpSocket};
#[cfg(feature = "ffi-dataplane")]
use tokio::io::{AsyncReadExt, AsyncWriteExt, ReadHalf, WriteHalf};
#[cfg(feature = "ffi-dataplane")]
use tokio_util::sync::CancellationToken;
#[cfg(feature = "ffi-dataplane")]
use uuid::Uuid;
#[cfg(feature = "ffi-dataplane")]
use crate::{
config_server::{in_config_server_callback, is_config_server_active_or_stopping},
error::{free_string, set_error_msg},
state::{INSTANCE_MANAGER, INSTANCE_NAME_ID_MAP},
};
#[cfg(feature = "ffi-dataplane")]
static NEXT_DATA_PLANE_HANDLE: AtomicU64 = AtomicU64::new(1);
#[cfg(feature = "ffi-dataplane")]
static DATA_PLANE_HANDLES: once_cell::sync::Lazy<DashMap<u64, DataPlaneHandle>> =
once_cell::sync::Lazy::new(DashMap::new);
#[cfg(feature = "ffi-dataplane")]
static DATA_PLANE_USAGE_LOCK: once_cell::sync::Lazy<RwLock<()>> =
once_cell::sync::Lazy::new(|| RwLock::new(()));
#[cfg(feature = "ffi-dataplane")]
pub(crate) struct DataPlaneHandle {
pub(crate) instance_id: uuid::Uuid,
pub(crate) runtime: tokio::runtime::Handle,
// Cancelled by close() to wake any in-flight op on this handle.
pub(crate) close_token: CancellationToken,
pub(crate) resource: DataPlaneResource,
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) struct TcpHalves {
pub(crate) read: tokio::sync::Mutex<ReadHalf<DataPlaneTcpStream>>,
pub(crate) write: tokio::sync::Mutex<WriteHalf<DataPlaneTcpStream>>,
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) enum DataPlaneResource {
Tcp(Arc<TcpHalves>),
TcpListener(Arc<tokio::sync::Mutex<DataPlaneTcpListener>>),
Udp(Arc<DataPlaneUdpSocket>),
}
// Several helper functions for FFI data plane operations to facilitate logic reuse.
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn next_handle() -> u64 {
NEXT_DATA_PLANE_HANDLE.fetch_add(1, Ordering::Relaxed)
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn timeout_duration(timeout_ms: u64) -> Duration {
Duration::from_millis(timeout_ms)
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) unsafe fn cstr_to_string(ptr: *const std::ffi::c_char, name: &str) -> Option<String> {
if ptr.is_null() {
set_error_msg(&format!("{} is null", name));
return None;
}
Some(
unsafe { std::ffi::CStr::from_ptr(ptr) }
.to_string_lossy()
.into_owned(),
)
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn get_instance_id(inst_name: &str) -> Option<uuid::Uuid> {
INSTANCE_NAME_ID_MAP.get(inst_name).map(|id| *id.value())
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn parse_socket_addr(host: &str, port: u16) -> Option<SocketAddr> {
let ip = match host.parse::<IpAddr>() {
Ok(ip) => ip,
Err(e) => {
set_error_msg(&format!("failed to parse ip address: {}", e));
return None;
}
};
Some(SocketAddr::new(ip, port))
}
/// Encode an IP address for FFI return. Returns `*mut c_char` to match
/// `CString::into_raw`; caller releases it via `free_string`.
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn into_ffi_ip_cstring(ip: IpAddr) -> Option<*mut std::ffi::c_char> {
match std::ffi::CString::new(ip.to_string()) {
Ok(s) => Some(s.into_raw()),
Err(e) => {
set_error_msg(&format!("failed to encode ip: {}", e));
None
}
}
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn get_runtime_handle(
inst_id: &uuid::Uuid,
deadline: std::time::Instant,
) -> Option<tokio::runtime::Handle> {
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
let Some(rt) = INSTANCE_MANAGER.data_plane_wait_runtime_handle(inst_id, remaining) else {
set_error_msg("instance runtime is not ready");
return None;
};
Some(rt)
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn insert_tcp_stream_handle(
instance_id: uuid::Uuid,
runtime: tokio::runtime::Handle,
stream: DataPlaneTcpStream,
) -> u64 {
let (rd, wr) = tokio::io::split(stream);
let handle = next_handle();
DATA_PLANE_HANDLES.insert(
handle,
DataPlaneHandle {
instance_id,
runtime,
close_token: CancellationToken::new(),
resource: DataPlaneResource::Tcp(Arc::new(TcpHalves {
read: tokio::sync::Mutex::new(rd),
write: tokio::sync::Mutex::new(wr),
})),
},
);
handle
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn insert_tcp_listener_handle(
instance_id: uuid::Uuid,
runtime: tokio::runtime::Handle,
listener: DataPlaneTcpListener,
) -> u64 {
let handle = next_handle();
DATA_PLANE_HANDLES.insert(
handle,
DataPlaneHandle {
instance_id,
runtime,
close_token: CancellationToken::new(),
resource: DataPlaneResource::TcpListener(Arc::new(tokio::sync::Mutex::new(listener))),
},
);
handle
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn insert_udp_socket_handle(
instance_id: uuid::Uuid,
runtime: tokio::runtime::Handle,
socket: DataPlaneUdpSocket,
) -> u64 {
let handle = next_handle();
DATA_PLANE_HANDLES.insert(
handle,
DataPlaneHandle {
instance_id,
runtime,
close_token: CancellationToken::new(),
resource: DataPlaneResource::Udp(Arc::new(socket)),
},
);
handle
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn get_tcp_stream(
handle: u64,
) -> Option<(Arc<TcpHalves>, tokio::runtime::Handle, CancellationToken)> {
get_tcp_stream_with_instance(handle)
.map(|(halves, runtime, close_token, _)| (halves, runtime, close_token))
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn get_tcp_stream_with_instance(
handle: u64,
) -> Option<(
Arc<TcpHalves>,
tokio::runtime::Handle,
CancellationToken,
uuid::Uuid,
)> {
let Some(h) = DATA_PLANE_HANDLES.get(&handle) else {
set_error_msg("tcp stream handle not found");
return None;
};
match &h.resource {
DataPlaneResource::Tcp(halves) => Some((
halves.clone(),
h.runtime.clone(),
h.close_token.clone(),
h.instance_id,
)),
DataPlaneResource::TcpListener(_) | DataPlaneResource::Udp(_) => {
set_error_msg("handle is not a tcp stream");
None
}
}
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn get_tcp_listener(
handle: u64,
) -> Option<(
Arc<tokio::sync::Mutex<DataPlaneTcpListener>>,
tokio::runtime::Handle,
CancellationToken,
uuid::Uuid,
)> {
let Some(h) = DATA_PLANE_HANDLES.get(&handle) else {
set_error_msg("tcp listener handle not found");
return None;
};
match &h.resource {
DataPlaneResource::TcpListener(listener) => Some((
listener.clone(),
h.runtime.clone(),
h.close_token.clone(),
h.instance_id,
)),
DataPlaneResource::Tcp(_) | DataPlaneResource::Udp(_) => {
set_error_msg("handle is not a tcp listener");
None
}
}
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn get_udp_socket(
handle: u64,
) -> Option<(
Arc<DataPlaneUdpSocket>,
tokio::runtime::Handle,
CancellationToken,
)> {
get_udp_socket_with_instance(handle)
.map(|(socket, runtime, close_token, _)| (socket, runtime, close_token))
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn get_udp_socket_with_instance(
handle: u64,
) -> Option<(
Arc<DataPlaneUdpSocket>,
tokio::runtime::Handle,
CancellationToken,
uuid::Uuid,
)> {
let Some(h) = DATA_PLANE_HANDLES.get(&handle) else {
set_error_msg("udp socket handle not found");
return None;
};
match &h.resource {
DataPlaneResource::Udp(socket) => Some((
socket.clone(),
h.runtime.clone(),
h.close_token.clone(),
h.instance_id,
)),
DataPlaneResource::Tcp(_) | DataPlaneResource::TcpListener(_) => {
set_error_msg("handle is not a udp socket");
None
}
}
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn remove_data_plane_handles_by_instance_ids(ids: &[Uuid]) {
if ids.is_empty() {
return;
}
let _data_plane_usage_guard = DATA_PLANE_USAGE_LOCK
.write()
.unwrap_or_else(|err| err.into_inner());
DATA_PLANE_HANDLES.retain(|_, handle| {
if ids.contains(&handle.instance_id) {
handle.close_token.cancel();
false
} else {
true
}
});
crate::data_plane_async::remove_ops_by_instance_ids(ids);
}
#[cfg(not(feature = "ffi-dataplane"))]
pub(crate) fn remove_data_plane_handles_by_instance_ids(_ids: &[uuid::Uuid]) {}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn data_plane_rejected() -> bool {
if in_config_server_callback() {
set_error_msg("cannot use data plane from config server callback");
true
} else if is_config_server_active_or_stopping() {
set_error_msg("cannot use data plane while config server client is active");
true
} else {
false
}
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn enter_data_plane_operation() -> Option<std::sync::RwLockReadGuard<'static, ()>> {
if data_plane_rejected() {
return None;
}
let guard = match DATA_PLANE_USAGE_LOCK.read() {
Ok(guard) => guard,
Err(err) => {
set_error_msg(&format!("failed to lock data plane usage: {}", err));
return None;
}
};
if data_plane_rejected() {
return None;
}
Some(guard)
}
/// Run an IO op on the resource's owning runtime, supporting
/// timeout and cancellation.
#[cfg(feature = "ffi-dataplane")]
async fn run_with_cancel<T, F>(
close_token: &CancellationToken,
timeout_ms: u64,
error_prefix: &str,
op: F,
) -> Option<Result<T, std::io::Error>>
where
F: Future<Output = Result<T, std::io::Error>>,
{
tokio::select! {
biased;
_ = close_token.cancelled() => {
set_error_msg(&format!("{}: handle closed", error_prefix));
None
}
res = tokio::time::timeout(timeout_duration(timeout_ms), op) => match res {
Ok(r) => Some(r),
Err(_) => {
set_error_msg(&format!("{} timed out", error_prefix));
None
}
}
}
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn lock_for_config_server_start()
-> Result<std::sync::RwLockWriteGuard<'static, ()>, String> {
let guard = DATA_PLANE_USAGE_LOCK
.write()
.map_err(|err| format!("failed to lock data plane usage: {}", err))?;
if !DATA_PLANE_HANDLES.is_empty() || crate::data_plane_async::has_live_ops() {
return Err("cannot start config server client while data plane is in use".to_string());
}
Ok(guard)
}
/// # Safety
/// Open a TCP stream through an EasyTier instance data plane. Returns 0 on
/// failure. On success, writes the local socket address chosen for this
/// connection into `out_local_ip` (a heap-allocated C string the caller must
/// release via `free_string`) and `out_local_port`. Both out pointers must be
/// non-null.
#[cfg(feature = "ffi-dataplane")]
pub(crate) unsafe fn data_plane_tcp_connect(
inst_name: *const std::ffi::c_char,
dst_ip: *const std::ffi::c_char,
dst_port: std::ffi::c_ushort,
timeout_ms: u64,
out_local_ip: *mut *const std::ffi::c_char,
out_local_port: *mut std::ffi::c_ushort,
) -> u64 {
let _data_plane_usage_guard = match enter_data_plane_operation() {
Some(guard) => guard,
None => return 0,
};
if out_local_ip.is_null() || out_local_port.is_null() {
set_error_msg("output pointer is null");
return 0;
}
let Some(inst_name) = (unsafe { cstr_to_string(inst_name, "inst_name") }) else {
return 0;
};
let Some(dst_ip) = (unsafe { cstr_to_string(dst_ip, "dst_ip") }) else {
return 0;
};
let Some(inst_id) = get_instance_id(&inst_name) else {
set_error_msg("instance not found");
return 0;
};
let Some(dst_addr) = parse_socket_addr(&dst_ip, dst_port) else {
return 0;
};
let deadline = std::time::Instant::now() + timeout_duration(timeout_ms);
let Some(runtime) = get_runtime_handle(&inst_id, deadline) else {
return 0;
};
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
let result =
runtime.block_on(INSTANCE_MANAGER.data_plane_tcp_connect(&inst_id, dst_addr, remaining));
match result {
Ok(stream) => {
let local_addr = stream.local_addr();
let Some(local_ip) = into_ffi_ip_cstring(local_addr.ip()) else {
return 0;
};
let handle = insert_tcp_stream_handle(inst_id, runtime, stream);
unsafe {
*out_local_ip = local_ip as *const std::ffi::c_char;
*out_local_port = local_addr.port();
}
handle
}
Err(e) => {
set_error_msg(&format!("failed to connect tcp data plane: {}", e));
0
}
}
}
/// # Safety
/// Bind a TCP listener through an EasyTier instance data plane. Returns 0 on
/// failure. The local address actually bound is written into `out_local_ip` /
/// `out_local_port`; the caller must release `*out_local_ip` via `free_string`.
#[cfg(feature = "ffi-dataplane")]
pub(crate) unsafe fn data_plane_tcp_bind(
inst_name: *const std::ffi::c_char,
local_port: std::ffi::c_ushort,
timeout_ms: u64,
out_local_ip: *mut *const std::ffi::c_char,
out_local_port: *mut std::ffi::c_ushort,
) -> u64 {
let _data_plane_usage_guard = match enter_data_plane_operation() {
Some(guard) => guard,
None => return 0,
};
if out_local_ip.is_null() || out_local_port.is_null() {
set_error_msg("output pointer is null");
return 0;
}
let Some(inst_name) = (unsafe { cstr_to_string(inst_name, "inst_name") }) else {
return 0;
};
let Some(inst_id) = get_instance_id(&inst_name) else {
set_error_msg("instance not found");
return 0;
};
let deadline = std::time::Instant::now() + timeout_duration(timeout_ms);
let Some(runtime) = get_runtime_handle(&inst_id, deadline) else {
return 0;
};
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
let result =
runtime.block_on(INSTANCE_MANAGER.data_plane_tcp_bind(&inst_id, local_port, remaining));
match result {
Ok(listener) => {
let local_addr = listener.local_addr();
let Some(local_ip) = into_ffi_ip_cstring(local_addr.ip()) else {
return 0;
};
let handle = insert_tcp_listener_handle(inst_id, runtime, listener);
unsafe {
*out_local_ip = local_ip as *const std::ffi::c_char;
*out_local_port = local_addr.port();
}
handle
}
Err(e) => {
set_error_msg(&format!("failed to bind tcp data plane: {}", e));
0
}
}
}
/// # Safety
/// Accept one connection from a TCP data-plane listener. Returns a TCP stream
/// handle, or 0 on failure. Local and peer addresses are written into out
/// parameters; returned IP strings must be released via `free_string`.
#[cfg(feature = "ffi-dataplane")]
pub(crate) unsafe fn data_plane_tcp_accept(
handle: u64,
timeout_ms: u64,
out_local_ip: *mut *const std::ffi::c_char,
out_local_port: *mut std::ffi::c_ushort,
out_peer_ip: *mut *const std::ffi::c_char,
out_peer_port: *mut std::ffi::c_ushort,
) -> u64 {
let _data_plane_usage_guard = match enter_data_plane_operation() {
Some(guard) => guard,
None => return 0,
};
if out_local_ip.is_null()
|| out_local_port.is_null()
|| out_peer_ip.is_null()
|| out_peer_port.is_null()
{
set_error_msg("output pointer is null");
return 0;
}
let Some((listener, runtime, close_token, instance_id)) = get_tcp_listener(handle) else {
return 0;
};
let ret = runtime.block_on(async move {
let mut listener = listener.lock().await;
run_with_cancel(
&close_token,
timeout_ms,
"tcp data plane accept",
listener.accept(),
)
.await
});
match ret {
Some(Ok((stream, peer_addr))) => {
let local_addr = stream.local_addr();
let Some(local_ip) = into_ffi_ip_cstring(local_addr.ip()) else {
return 0;
};
let Some(peer_ip) = into_ffi_ip_cstring(peer_addr.ip()) else {
free_string(local_ip);
return 0;
};
let stream_handle = insert_tcp_stream_handle(instance_id, runtime, stream);
unsafe {
*out_local_ip = local_ip as *const std::ffi::c_char;
*out_local_port = local_addr.port();
*out_peer_ip = peer_ip as *const std::ffi::c_char;
*out_peer_port = peer_addr.port();
}
stream_handle
}
Some(Err(e)) => {
set_error_msg(&format!("failed to accept tcp data plane: {}", e));
0
}
None => 0,
}
}
/// # Safety
/// Read from a TCP data-plane stream.
#[cfg(feature = "ffi-dataplane")]
pub(crate) unsafe fn data_plane_tcp_read(
handle: u64,
buf: *mut std::ffi::c_uchar,
len: u32,
timeout_ms: u64,
) -> std::ffi::c_int {
let _data_plane_usage_guard = match enter_data_plane_operation() {
Some(guard) => guard,
None => return -1,
};
if buf.is_null() {
set_error_msg("buf is null");
return -1;
}
let Some((halves, runtime, close_token)) = get_tcp_stream(handle) else {
return -1;
};
// Safety: caller-owned buffer outlives this blocking call.
let buf = unsafe { std::slice::from_raw_parts_mut(buf, len as usize) };
runtime.block_on(async move {
let mut rd = halves.read.lock().await;
match run_with_cancel(
&close_token,
timeout_ms,
"failed to read tcp data plane",
rd.read(buf),
)
.await
{
Some(Ok(n)) => n as std::ffi::c_int,
Some(Err(e)) => {
set_error_msg(&format!("failed to read tcp data plane: {}", e));
-1
}
None => -1,
}
})
}
/// # Safety
/// Write to a TCP data-plane stream.
#[cfg(feature = "ffi-dataplane")]
pub(crate) unsafe fn data_plane_tcp_write(
handle: u64,
buf: *const std::ffi::c_uchar,
len: u32,
timeout_ms: u64,
) -> std::ffi::c_int {
let _data_plane_usage_guard = match enter_data_plane_operation() {
Some(guard) => guard,
None => return -1,
};
if buf.is_null() {
set_error_msg("buf is null");
return -1;
}
let Some((halves, runtime, close_token)) = get_tcp_stream(handle) else {
return -1;
};
let total = len as usize;
// Safety: caller-owned buffer outlives this blocking call.
let buf = unsafe { std::slice::from_raw_parts(buf, total) };
runtime.block_on(async move {
let mut wr = halves.write.lock().await;
// Use `write_all` to honor `net.Conn::Write` semantics on the Go side
// (must write everything or return an error); single `write()` can
// silently short-write and corrupt streams that the caller assumes are
// fully written.
match run_with_cancel(
&close_token,
timeout_ms,
"failed to write tcp data plane",
wr.write_all(buf),
)
.await
{
Some(Ok(())) => total as std::ffi::c_int,
Some(Err(e)) => {
set_error_msg(&format!("failed to write tcp data plane: {}", e));
-1
}
None => -1,
}
})
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn data_plane_tcp_close(handle: u64) -> std::ffi::c_int {
let _data_plane_usage_guard = match enter_data_plane_operation() {
Some(guard) => guard,
None => return -1,
};
crate::data_plane_async::cancel_ops_for_handle(handle);
let Some((_, h)) = DATA_PLANE_HANDLES.remove_if(&handle, |_, e| {
matches!(e.resource, DataPlaneResource::Tcp(_))
}) else {
set_error_msg(if DATA_PLANE_HANDLES.contains_key(&handle) {
"handle is not a tcp stream"
} else {
"tcp stream handle not found"
});
return -1;
};
h.close_token.cancel();
if let DataPlaneResource::Tcp(halves) = h.resource {
// Best-effort half-close; if write half is in use, the in-flight call
// observes the cancel token and releases the lock shortly after.
h.runtime.spawn(async move {
if let Ok(mut wr) = halves.write.try_lock() {
let _ = wr.shutdown().await;
}
});
}
0
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn data_plane_tcp_listener_close(handle: u64) -> std::ffi::c_int {
let _data_plane_usage_guard = match enter_data_plane_operation() {
Some(guard) => guard,
None => return -1,
};
crate::data_plane_async::cancel_ops_for_handle(handle);
let Some((_, h)) = DATA_PLANE_HANDLES.remove_if(&handle, |_, e| {
matches!(e.resource, DataPlaneResource::TcpListener(_))
}) else {
set_error_msg(if DATA_PLANE_HANDLES.contains_key(&handle) {
"handle is not a tcp listener"
} else {
"tcp listener handle not found"
});
return -1;
};
h.close_token.cancel();
0
}
/// # Safety
/// Bind a UDP socket through an EasyTier instance data plane. Returns 0 on
/// failure. The local address actually bound (which may differ from the
/// requested port when `local_port == 0`) is written into `out_local_ip` /
/// `out_local_port`; the caller must release `*out_local_ip` via `free_string`.
#[cfg(feature = "ffi-dataplane")]
pub(crate) unsafe fn data_plane_udp_bind(
inst_name: *const std::ffi::c_char,
local_port: std::ffi::c_ushort,
timeout_ms: u64,
out_local_ip: *mut *const std::ffi::c_char,
out_local_port: *mut std::ffi::c_ushort,
) -> u64 {
let _data_plane_usage_guard = match enter_data_plane_operation() {
Some(guard) => guard,
None => return 0,
};
if out_local_ip.is_null() || out_local_port.is_null() {
set_error_msg("output pointer is null");
return 0;
}
let Some(inst_name) = (unsafe { cstr_to_string(inst_name, "inst_name") }) else {
return 0;
};
let Some(inst_id) = get_instance_id(&inst_name) else {
set_error_msg("instance not found");
return 0;
};
let deadline = std::time::Instant::now() + timeout_duration(timeout_ms);
let Some(runtime) = get_runtime_handle(&inst_id, deadline) else {
return 0;
};
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
let result =
runtime.block_on(INSTANCE_MANAGER.data_plane_udp_bind(&inst_id, local_port, remaining));
match result {
Ok(socket) => {
let local_addr = socket.local_addr();
let Some(local_ip) = into_ffi_ip_cstring(local_addr.ip()) else {
return 0;
};
let handle = insert_udp_socket_handle(inst_id, runtime, socket);
unsafe {
*out_local_ip = local_ip as *const std::ffi::c_char;
*out_local_port = local_addr.port();
}
handle
}
Err(e) => {
set_error_msg(&format!("failed to bind udp data plane: {}", e));
0
}
}
}
/// # Safety
/// Send a datagram through a UDP data-plane socket.
#[cfg(feature = "ffi-dataplane")]
pub(crate) unsafe fn data_plane_udp_send_to(
handle: u64,
dst_ip: *const std::ffi::c_char,
dst_port: std::ffi::c_ushort,
buf: *const std::ffi::c_uchar,
len: u32,
timeout_ms: u64,
) -> std::ffi::c_int {
let _data_plane_usage_guard = match enter_data_plane_operation() {
Some(guard) => guard,
None => return -1,
};
if buf.is_null() {
set_error_msg("buf is null");
return -1;
}
let Some(dst_ip) = (unsafe { cstr_to_string(dst_ip, "dst_ip") }) else {
return -1;
};
let Some(dst_addr) = parse_socket_addr(&dst_ip, dst_port) else {
return -1;
};
let Some((socket, runtime, close_token)) = get_udp_socket(handle) else {
return -1;
};
let total = len as usize;
// Safety: caller-owned buffer outlives this blocking call.
let buf = unsafe { std::slice::from_raw_parts(buf, total) };
runtime.block_on(async move {
match run_with_cancel(
&close_token,
timeout_ms,
"failed to send udp data plane",
socket.send_to(buf, dst_addr),
)
.await
{
Some(Ok(n)) => n as std::ffi::c_int,
Some(Err(e)) => {
set_error_msg(&format!("failed to send udp data plane: {}", e));
-1
}
None => -1,
}
})
}
/// # Safety
/// Receive a datagram from a UDP data-plane socket.
#[cfg(feature = "ffi-dataplane")]
pub(crate) unsafe fn data_plane_udp_recv_from(
handle: u64,
buf: *mut std::ffi::c_uchar,
len: u32,
out_ip: *mut *const std::ffi::c_char,
out_port: *mut std::ffi::c_ushort,
timeout_ms: u64,
) -> std::ffi::c_int {
let _data_plane_usage_guard = match enter_data_plane_operation() {
Some(guard) => guard,
None => return -1,
};
if buf.is_null() || out_ip.is_null() || out_port.is_null() {
set_error_msg("output pointer is null");
return -1;
}
let Some((socket, runtime, close_token)) = get_udp_socket(handle) else {
return -1;
};
let total = len as usize;
// Safety: caller-owned buffer outlives this blocking call.
let buf = unsafe { std::slice::from_raw_parts_mut(buf, total) };
let ret = runtime.block_on(run_with_cancel(
&close_token,
timeout_ms,
"udp data plane receive",
socket.recv_from(buf),
));
match ret {
Some(Ok((n, addr))) => {
// The returned ip pointer must be released by the caller via
// `free_string` (which calls `CString::from_raw`, matching
// `CString::into_raw` here).
let Some(ip_cstr) = into_ffi_ip_cstring(addr.ip()) else {
return -1;
};
unsafe {
*out_ip = ip_cstr as *const std::ffi::c_char;
*out_port = addr.port() as std::ffi::c_ushort;
}
n as std::ffi::c_int
}
Some(Err(e)) => {
set_error_msg(&format!("failed to receive udp data plane: {}", e));
-1
}
None => -1,
}
}
#[cfg(feature = "ffi-dataplane")]
pub(crate) fn data_plane_udp_close(handle: u64) -> std::ffi::c_int {
let _data_plane_usage_guard = match enter_data_plane_operation() {
Some(guard) => guard,
None => return -1,
};
crate::data_plane_async::cancel_ops_for_handle(handle);
let Some((_, h)) = DATA_PLANE_HANDLES.remove_if(&handle, |_, e| {
matches!(e.resource, DataPlaneResource::Udp(_))
}) else {
set_error_msg(if DATA_PLANE_HANDLES.contains_key(&handle) {
"handle is not a udp socket"
} else {
"udp socket handle not found"
});
return -1;
};
h.close_token.cancel();
0
}
#[cfg(all(test, feature = "ffi-dataplane"))]
mod tests {
use super::*;
use std::{sync::mpsc, time::Duration};
#[test]
fn config_server_start_waits_for_data_plane_operation() {
let read_guard = DATA_PLANE_USAGE_LOCK.read().unwrap();
let (done_tx, done_rx) = mpsc::channel();
let waiter = std::thread::spawn(move || {
let _write_guard = lock_for_config_server_start().unwrap();
done_tx.send(()).unwrap();
});
assert!(done_rx.recv_timeout(Duration::from_millis(100)).is_err());
drop(read_guard);
done_rx.recv_timeout(Duration::from_secs(5)).unwrap();
waiter.join().unwrap();
}
#[test]
fn instance_cleanup_waits_for_data_plane_operation() {
let read_guard = DATA_PLANE_USAGE_LOCK.read().unwrap();
let instance_id = Uuid::new_v4();
let (done_tx, done_rx) = mpsc::channel();
let cleaner = std::thread::spawn(move || {
remove_data_plane_handles_by_instance_ids(&[instance_id]);
done_tx.send(()).unwrap();
});
assert!(done_rx.recv_timeout(Duration::from_millis(100)).is_err());
drop(read_guard);
done_rx.recv_timeout(Duration::from_secs(5)).unwrap();
cleaner.join().unwrap();
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,65 @@
use std::{
cell::RefCell,
ffi::{CString, c_char},
};
thread_local! {
// # Thread Safety
// set_error_msg and get_error_msg must be called on the same thread to
// get correct error. And since `Handle::block_on` polls the top-level
// future on the calling thread, set_error_msg always runs on the same
// thread as the corresponding get_error_msg.
static ERROR_MSG: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
}
pub(crate) fn set_error_msg(msg: &str) {
ERROR_MSG.with(|cell| {
let mut buf = cell.borrow_mut();
buf.clear();
buf.extend_from_slice(msg.as_bytes());
});
}
fn thread_local_error_msg() -> Option<String> {
ERROR_MSG.with(|cell| {
let buf = cell.borrow();
if buf.is_empty() {
None
} else {
Some(String::from_utf8_lossy(&buf).into_owned())
}
})
}
pub(crate) unsafe fn get_error_msg(out: *mut *const c_char) {
let msg = match (
thread_local_error_msg(),
crate::config_server::last_callback_error(),
) {
(Some(error), Some(callback_error)) => Some(format!(
"{}; config server callback error: {}",
error, callback_error
)),
(Some(error), None) => Some(error),
(None, Some(callback_error)) => {
Some(format!("config server callback error: {}", callback_error))
}
(None, None) => None,
};
let cstr = msg.and_then(|msg| CString::new(msg).ok());
unsafe {
*out = match cstr {
Some(s) => s.into_raw() as *const c_char,
None => std::ptr::null(),
};
}
}
pub(crate) fn free_string(s: *const c_char) {
if s.is_null() {
return;
}
unsafe {
let _ = CString::from_raw(s as *mut c_char);
}
}
@@ -0,0 +1,366 @@
use std::ffi::{CString, c_char, c_int};
use easytier::common::config::{ConfigFileControl, ConfigLoader as _, TomlConfigLoader};
use crate::{
config_server::{
in_config_server_callback, remove_config_server_tracked_instance_ids,
wait_for_config_server_delivery,
},
data_plane::remove_data_plane_handles_by_instance_ids,
error::set_error_msg,
state::{
INSTANCE_MANAGER, INSTANCE_MUTATION_LOCK, INSTANCE_NAME_ID_MAP, instance_name_exists,
lock_remote_instance_mutation,
},
types::KeyValuePair,
};
/// # Safety
/// Set the tun fd
pub(crate) unsafe fn set_tun_fd(inst_name: *const c_char, fd: c_int) -> c_int {
let inst_name = unsafe {
assert!(!inst_name.is_null());
std::ffi::CStr::from_ptr(inst_name)
.to_string_lossy()
.into_owned()
};
if !INSTANCE_NAME_ID_MAP.contains_key(&inst_name) {
return -1;
}
let inst_id = *INSTANCE_NAME_ID_MAP
.get(&inst_name)
.as_ref()
.unwrap()
.value();
match INSTANCE_MANAGER.set_tun_fd(&inst_id, fd) {
Ok(_) => 0,
Err(_) => -1,
}
}
/// # Safety
/// Parse the config
pub(crate) unsafe fn parse_config(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int {
let cfg_str = unsafe {
assert!(!cfg_str.is_null());
std::ffi::CStr::from_ptr(cfg_str)
.to_string_lossy()
.into_owned()
};
if let Err(e) = TomlConfigLoader::new_from_str(&cfg_str) {
set_error_msg(&format!("failed to parse config: {:?}", e));
return -1;
}
0
}
/// # Safety
/// Run the network instance
pub(crate) unsafe fn run_network_instance(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int {
if in_config_server_callback() {
set_error_msg("cannot run network instance from config server callback");
return -1;
}
let cfg_str = unsafe {
assert!(!cfg_str.is_null());
std::ffi::CStr::from_ptr(cfg_str)
.to_string_lossy()
.into_owned()
};
let cfg = match TomlConfigLoader::new_from_str(&cfg_str) {
Ok(cfg) => cfg,
Err(e) => {
set_error_msg(&format!("failed to parse config: {}", e));
return -1;
}
};
let inst_name = cfg.get_inst_name();
wait_for_config_server_delivery();
let _remote_mutation_guard = lock_remote_instance_mutation();
let _mutation_guard = match INSTANCE_MUTATION_LOCK.lock() {
Ok(guard) => guard,
Err(err) => {
set_error_msg(&format!("failed to lock instance mutation: {}", err));
return -1;
}
};
if instance_name_exists(&inst_name) {
set_error_msg("instance already exists");
return -1;
}
let instance_id =
match INSTANCE_MANAGER.run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG) {
Ok(id) => id,
Err(e) => {
set_error_msg(&format!("failed to start instance: {}", e));
return -1;
}
};
INSTANCE_NAME_ID_MAP.insert(inst_name, instance_id);
0
}
unsafe fn parse_instance_names(
inst_names: *const *const c_char,
length: usize,
) -> Option<Vec<String>> {
if length == 0 {
return Some(Vec::new());
}
if inst_names.is_null() {
set_error_msg("inst_names is null");
return None;
}
let names = unsafe { std::slice::from_raw_parts(inst_names, length) };
let mut parsed = Vec::with_capacity(length);
for (index, &name) in names.iter().enumerate() {
if name.is_null() {
set_error_msg(&format!("inst_names[{}] is null", index));
return None;
}
parsed.push(
unsafe { std::ffi::CStr::from_ptr(name) }
.to_string_lossy()
.into_owned(),
);
}
Some(parsed)
}
/// # Safety
/// Retain the network instance
pub(crate) unsafe fn retain_network_instance(
inst_names: *const *const std::ffi::c_char,
length: usize,
) -> std::ffi::c_int {
if in_config_server_callback() {
set_error_msg("cannot retain network instances from config server callback");
return -1;
}
wait_for_config_server_delivery();
let _remote_mutation_guard = lock_remote_instance_mutation();
let _mutation_guard = match INSTANCE_MUTATION_LOCK.lock() {
Ok(guard) => guard,
Err(err) => {
set_error_msg(&format!("failed to lock instance mutation: {}", err));
return -1;
}
};
if length == 0 {
let removed_ids = INSTANCE_MANAGER.list_network_instance_ids();
if let Err(e) = INSTANCE_MANAGER.delete_network_instance(removed_ids.clone()) {
set_error_msg(&format!("failed to delete instances: {}", e));
return -1;
}
remove_config_server_tracked_instance_ids(&removed_ids);
remove_data_plane_handles_by_instance_ids(&removed_ids);
INSTANCE_NAME_ID_MAP.clear();
return 0;
}
let Some(inst_names) = (unsafe { parse_instance_names(inst_names, length) }) else {
return -1;
};
let removed_ids = INSTANCE_MANAGER
.list_network_instance_ids()
.into_iter()
.filter(|id| {
INSTANCE_MANAGER
.get_instance_name(id)
.is_none_or(|name| !inst_names.contains(&name))
})
.collect::<Vec<_>>();
if let Err(e) = INSTANCE_MANAGER.delete_network_instance(removed_ids.clone()) {
set_error_msg(&format!("failed to delete instances: {}", e));
return -1;
}
remove_config_server_tracked_instance_ids(&removed_ids);
remove_data_plane_handles_by_instance_ids(&removed_ids);
INSTANCE_NAME_ID_MAP.retain(|k, _| inst_names.contains(k));
0
}
/// # Safety
/// Delete named network instances.
pub(crate) unsafe fn delete_network_instance(
inst_names: *const *const std::ffi::c_char,
length: usize,
) -> std::ffi::c_int {
if in_config_server_callback() {
set_error_msg("cannot delete network instances from config server callback");
return -1;
}
wait_for_config_server_delivery();
let _remote_mutation_guard = lock_remote_instance_mutation();
let _mutation_guard = match INSTANCE_MUTATION_LOCK.lock() {
Ok(guard) => guard,
Err(err) => {
set_error_msg(&format!("failed to lock instance mutation: {}", err));
return -1;
}
};
if length == 0 {
return 0;
}
let Some(inst_names) = (unsafe { parse_instance_names(inst_names, length) }) else {
return -1;
};
let removed_ids = inst_names
.iter()
.filter_map(|name| INSTANCE_NAME_ID_MAP.get(name).map(|id| *id.value()))
.collect::<Vec<_>>();
if let Err(e) = INSTANCE_MANAGER.delete_network_instance(removed_ids.clone()) {
set_error_msg(&format!("failed to delete instances: {}", e));
return -1;
}
remove_config_server_tracked_instance_ids(&removed_ids);
remove_data_plane_handles_by_instance_ids(&removed_ids);
for name in inst_names {
INSTANCE_NAME_ID_MAP.remove(&name);
}
0
}
/// # Safety
/// Collect the network infos
pub(crate) unsafe fn collect_network_infos(
infos: *mut KeyValuePair,
max_length: usize,
) -> std::ffi::c_int {
if in_config_server_callback() {
set_error_msg("cannot collect network infos from config server callback");
return -1;
}
if max_length == 0 {
return 0;
}
let infos = unsafe {
assert!(!infos.is_null());
std::slice::from_raw_parts_mut(infos, max_length)
};
let collected_infos = match INSTANCE_MANAGER.collect_network_infos_sync() {
Ok(infos) => infos,
Err(e) => {
set_error_msg(&format!("failed to collect network infos: {}", e));
return -1;
}
};
let mut index = 0;
for (instance_id, value) in collected_infos.iter() {
if index >= max_length {
break;
}
let Some(key) = INSTANCE_MANAGER.get_instance_name(instance_id) else {
continue;
};
// convert value to json string
let value = match serde_json::to_string(&value) {
Ok(value) => value,
Err(e) => {
set_error_msg(&format!("failed to serialize instance info: {}", e));
return -1;
}
};
infos[index] = KeyValuePair {
key: std::ffi::CString::new(key).unwrap().into_raw(),
value: std::ffi::CString::new(value).unwrap().into_raw(),
};
index += 1;
}
index as std::ffi::c_int
}
/// # Safety
/// List the instance names and IDs known by the FFI instance manager.
pub(crate) unsafe fn list_instance(infos: *mut KeyValuePair, max_length: usize) -> std::ffi::c_int {
if in_config_server_callback() {
set_error_msg("cannot list instances from config server callback");
return -1;
}
if max_length == 0 {
return 0;
}
if infos.is_null() {
set_error_msg("infos is null");
return -1;
}
let infos = unsafe { std::slice::from_raw_parts_mut(infos, max_length) };
let mut instances = INSTANCE_MANAGER
.list_network_instance_ids()
.into_iter()
.filter_map(|id| {
INSTANCE_MANAGER
.get_instance_name(&id)
.map(|name| (name, id))
})
.collect::<Vec<_>>();
instances.sort_by(|(left_name, left_id), (right_name, right_id)| {
left_name
.cmp(right_name)
.then_with(|| left_id.to_string().cmp(&right_id.to_string()))
});
let encoded_instances = match instances
.into_iter()
.take(max_length)
.map(|(name, id)| {
let key = CString::new(name)
.map_err(|err| format!("failed to encode instance name: {}", err))?;
let value = CString::new(id.to_string())
.map_err(|err| format!("failed to encode instance id: {}", err))?;
Ok((key, value))
})
.collect::<Result<Vec<_>, String>>()
{
Ok(value) => value,
Err(err) => {
set_error_msg(&err);
return -1;
}
};
let count = encoded_instances.len();
for (index, (key, value)) in encoded_instances.into_iter().enumerate() {
infos[index] = KeyValuePair {
key: key.into_raw(),
value: value.into_raw(),
};
}
count as std::ffi::c_int
}
@@ -0,0 +1,100 @@
use std::ffi::{CString, c_char, c_int};
use crate::{
config_server::in_config_server_callback,
error::set_error_msg,
state::{ASYNC_RUNTIME, INSTANCE_MANAGER},
strings::{c_str_to_string, optional_c_str_to_string},
};
/// # Safety
/// See `crate::call_json_rpc`.
pub(crate) unsafe fn call_json_rpc(
service_name: *const c_char,
method_name: *const c_char,
domain_name: *const c_char,
payload_json: *const c_char,
out_response_json: *mut *const c_char,
) -> c_int {
if out_response_json.is_null() {
set_error_msg("out_response_json is null");
return -1;
}
unsafe {
*out_response_json = std::ptr::null();
}
if in_config_server_callback() {
set_error_msg("cannot call JSON RPC from config server callback");
return -1;
}
let service_name = match unsafe { c_str_to_string(service_name, "service_name") } {
Ok(value) => value,
Err(err) => {
set_error_msg(&err);
return -1;
}
};
let method_name = match unsafe { c_str_to_string(method_name, "method_name") } {
Ok(value) => value,
Err(err) => {
set_error_msg(&err);
return -1;
}
};
let domain_name = match unsafe { optional_c_str_to_string(domain_name, "domain_name") } {
Ok(value) => value,
Err(err) => {
set_error_msg(&err);
return -1;
}
};
let payload_json = match unsafe { c_str_to_string(payload_json, "payload_json") } {
Ok(value) => value,
Err(err) => {
set_error_msg(&err);
return -1;
}
};
let payload = match serde_json::from_str::<serde_json::Value>(&payload_json) {
Ok(value) => value,
Err(err) => {
set_error_msg(&format!("failed to parse payload_json: {}", err));
return -1;
}
};
let response = match ASYNC_RUNTIME.block_on(easytier::rpc_service::call_json_rpc(
&INSTANCE_MANAGER,
&service_name,
&method_name,
domain_name.as_deref(),
payload,
)) {
Ok(value) => value,
Err(err) => {
set_error_msg(&format!("RPC Error: {}", err));
return -1;
}
};
let response_json = match serde_json::to_string(&response) {
Ok(value) => value,
Err(err) => {
set_error_msg(&format!("failed to serialize RPC response: {}", err));
return -1;
}
};
let response_json = match CString::new(response_json) {
Ok(value) => value,
Err(err) => {
set_error_msg(&format!("failed to allocate RPC response: {}", err));
return -1;
}
};
unsafe {
*out_response_json = response_json.into_raw();
}
0
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,54 @@
use std::sync::{Arc, Mutex};
use dashmap::DashMap;
use easytier::instance_manager::NetworkInstanceManager;
use tokio::runtime::{Builder, Runtime};
use uuid::Uuid;
pub(crate) static INSTANCE_NAME_ID_MAP: once_cell::sync::Lazy<DashMap<String, Uuid>> =
once_cell::sync::Lazy::new(DashMap::new);
pub(crate) static INSTANCE_MANAGER: once_cell::sync::Lazy<Arc<NetworkInstanceManager>> =
once_cell::sync::Lazy::new(|| Arc::new(NetworkInstanceManager::new()));
pub(crate) static ASYNC_RUNTIME: once_cell::sync::Lazy<Runtime> =
once_cell::sync::Lazy::new(|| {
Builder::new_multi_thread()
.enable_all()
.build()
.expect("tokio runtime for easytier-ffi")
});
pub(crate) static INSTANCE_MUTATION_LOCK: once_cell::sync::Lazy<Mutex<()>> =
once_cell::sync::Lazy::new(|| Mutex::new(()));
pub(crate) fn remove_instance_name_ids(ids: &[Uuid]) {
if ids.is_empty() {
return;
}
INSTANCE_NAME_ID_MAP.retain(|_, instance_id| !ids.contains(instance_id));
}
pub(crate) fn lock_remote_instance_mutation() -> tokio::sync::OwnedMutexGuard<()> {
INSTANCE_MANAGER
.remote_mutation_lock()
.blocking_lock_owned()
}
pub(crate) fn instance_name_exists(inst_name: &str) -> bool {
find_instance_id_by_name(inst_name).is_some()
}
pub(crate) fn find_instance_id_by_name(inst_name: &str) -> Option<Uuid> {
INSTANCE_NAME_ID_MAP
.get(inst_name)
.map(|id| *id)
.or_else(|| {
INSTANCE_MANAGER
.list_network_instance_ids()
.into_iter()
.find(|id| {
INSTANCE_MANAGER
.get_instance_name(id)
.is_some_and(|name| name == inst_name)
})
})
}
@@ -0,0 +1,23 @@
use std::ffi::{CStr, c_char};
pub(crate) unsafe fn c_str_to_string(ptr: *const c_char, name: &str) -> Result<String, String> {
if ptr.is_null() {
return Err(format!("{} is null", name));
}
unsafe { CStr::from_ptr(ptr) }
.to_str()
.map(|value| value.to_string())
.map_err(|err| format!("{} is not valid UTF-8: {}", name, err))
}
pub(crate) unsafe fn optional_c_str_to_string(
ptr: *const c_char,
name: &str,
) -> Result<Option<String>, String> {
if ptr.is_null() {
return Ok(None);
}
unsafe { c_str_to_string(ptr, name) }.map(Some)
}
+766
View File
@@ -0,0 +1,766 @@
use crate::{
config_server::{
ConfigServerCallbackScope, ManagedConfigServerClientHooks, set_active_for_test,
},
state::{
INSTANCE_MANAGER, INSTANCE_NAME_ID_MAP, find_instance_id_by_name,
lock_remote_instance_mutation, remove_instance_name_ids,
},
*,
};
use easytier::{
common::config::{ConfigFileControl, ConfigLoader as _, TomlConfigLoader},
web_client::WebClientHooks,
};
use serde_json::Value;
use std::{
collections::HashSet,
ffi::{CStr, CString, c_char, c_void},
sync::{Mutex, mpsc},
time::Duration,
};
use uuid::Uuid;
#[test]
fn test_parse_config() {
let cfg_str = r#"
inst_name = "test"
network = "test_network"
"#;
let cstr = std::ffi::CString::new(cfg_str).unwrap();
unsafe {
assert_eq!(parse_config(cstr.as_ptr()), 0);
}
}
#[test]
fn test_run_network_instance() {
let cfg_str = r#"
inst_name = "test"
network = "test_network"
"#;
let cstr = std::ffi::CString::new(cfg_str).unwrap();
unsafe {
assert_eq!(run_network_instance(cstr.as_ptr()), 0);
}
}
#[test]
fn get_error_msg_returns_config_server_callback_error() {
let hooks = ManagedConfigServerClientHooks::new(None, std::ptr::null_mut());
let callback_error = format!("callback delivery failed {}", Uuid::new_v4());
crate::config_server::clear_last_callback_error();
hooks.note_callback_error(callback_error.clone());
unsafe {
let mut error_ptr: *const c_char = std::ptr::null();
get_error_msg(&mut error_ptr);
assert!(!error_ptr.is_null());
let error_msg = CStr::from_ptr(error_ptr).to_string_lossy().into_owned();
free_string(error_ptr);
assert!(error_msg.contains(&callback_error));
}
crate::config_server::clear_last_callback_error();
}
unsafe extern "C" fn record_config_server_event(event_json: *const c_char, user_data: *mut c_void) {
let events = unsafe { &*(user_data as *const Mutex<Vec<String>>) };
events.lock().unwrap().push(
unsafe { CStr::from_ptr(event_json) }
.to_string_lossy()
.into_owned(),
);
}
fn take_last_error() -> Option<String> {
unsafe {
let mut error_ptr: *const c_char = std::ptr::null();
get_error_msg(&mut error_ptr);
if error_ptr.is_null() {
None
} else {
let error = CStr::from_ptr(error_ptr).to_string_lossy().into_owned();
free_string(error_ptr);
Some(error)
}
}
}
fn free_key_value_pairs(infos: &[KeyValuePair]) {
for info in infos {
free_string(info.key);
free_string(info.value);
}
}
#[test]
fn list_instance_returns_instance_names_and_ids() {
let instance_id = Uuid::new_v4();
let instance_name = format!("list-instance-{}", instance_id);
let cfg = TomlConfigLoader::default();
cfg.set_id(instance_id);
cfg.set_inst_name(instance_name.clone());
INSTANCE_MANAGER
.run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG)
.unwrap();
INSTANCE_NAME_ID_MAP.insert(instance_name.clone(), instance_id);
let mut infos = vec![
KeyValuePair {
key: std::ptr::null(),
value: std::ptr::null(),
};
16
];
let count = unsafe { list_instance(infos.as_mut_ptr(), infos.len()) };
assert!(count > 0);
let mut found = false;
for info in infos.iter().take(count as usize) {
let key = unsafe { CStr::from_ptr(info.key) }.to_string_lossy();
let value = unsafe { CStr::from_ptr(info.value) }.to_string_lossy();
if key == instance_name {
assert_eq!(value, instance_id.to_string());
found = true;
}
}
free_key_value_pairs(&infos[..count as usize]);
INSTANCE_MANAGER
.delete_network_instance(vec![instance_id])
.unwrap();
remove_instance_name_ids(&[instance_id]);
assert!(found);
}
#[test]
fn list_instance_allows_zero_length() {
assert_eq!(unsafe { list_instance(std::ptr::null_mut(), 0) }, 0);
}
#[test]
fn list_instance_rejects_null_output_pointer() {
assert_eq!(unsafe { list_instance(std::ptr::null_mut(), 1) }, -1);
assert!(take_last_error().unwrap().contains("infos is null"));
}
#[test]
fn call_json_rpc_returns_logger_response() {
let service = CString::new("api.logger.LoggerRpcService").unwrap();
let method = CString::new("get_logger_config").unwrap();
let payload = CString::new("{}").unwrap();
let mut response_ptr: *const c_char = std::ptr::null();
assert_eq!(
unsafe {
call_json_rpc(
service.as_ptr(),
method.as_ptr(),
std::ptr::null(),
payload.as_ptr(),
&mut response_ptr,
)
},
0
);
assert!(!response_ptr.is_null());
let response = unsafe { CStr::from_ptr(response_ptr) }
.to_string_lossy()
.into_owned();
free_string(response_ptr);
let response: Value = serde_json::from_str(&response).unwrap();
assert!(response.get("level").is_some());
}
#[test]
fn call_json_rpc_rejects_instance_management_service() {
let service = CString::new("api.manage.WebClientService").unwrap();
let method = CString::new("list_network_instance").unwrap();
let payload = CString::new("{}").unwrap();
let mut response_ptr: *const c_char = std::ptr::null();
assert_eq!(
unsafe {
call_json_rpc(
service.as_ptr(),
method.as_ptr(),
std::ptr::null(),
payload.as_ptr(),
&mut response_ptr,
)
},
-1
);
assert!(response_ptr.is_null());
assert!(take_last_error().unwrap().contains("not exposed"));
}
#[test]
fn call_json_rpc_rejects_malformed_payload_json() {
let service = CString::new("api.logger.LoggerRpcService").unwrap();
let method = CString::new("get_logger_config").unwrap();
let payload = CString::new("{").unwrap();
let mut response_ptr: *const c_char = std::ptr::null();
assert_eq!(
unsafe {
call_json_rpc(
service.as_ptr(),
method.as_ptr(),
std::ptr::null(),
payload.as_ptr(),
&mut response_ptr,
)
},
-1
);
assert!(response_ptr.is_null());
assert!(
take_last_error()
.unwrap()
.contains("failed to parse payload_json")
);
}
#[test]
fn call_json_rpc_rejects_null_output_pointer() {
let service = CString::new("api.logger.LoggerRpcService").unwrap();
let method = CString::new("get_logger_config").unwrap();
let payload = CString::new("{}").unwrap();
assert_eq!(
unsafe {
call_json_rpc(
service.as_ptr(),
method.as_ptr(),
std::ptr::null(),
payload.as_ptr(),
std::ptr::null_mut(),
)
},
-1
);
assert!(
take_last_error()
.unwrap()
.contains("out_response_json is null")
);
}
#[tokio::test]
async fn config_server_hooks_emit_run_event() {
let events: Mutex<Vec<String>> = Mutex::new(Vec::new());
let hooks = ManagedConfigServerClientHooks::new(
Some(record_config_server_event),
&events as *const _ as *mut c_void,
);
let instance_id = Uuid::new_v4();
let cfg = TomlConfigLoader::default();
cfg.set_id(instance_id);
let inst_name = format!("test-{}", instance_id);
cfg.set_inst_name(inst_name.clone());
hooks.pre_run_network_instance(&cfg).await.unwrap();
INSTANCE_MANAGER
.run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG)
.unwrap();
hooks.post_run_network_instance(&instance_id).await.unwrap();
let duplicate_cfg = TomlConfigLoader::default();
duplicate_cfg.set_inst_name(inst_name);
duplicate_cfg.set_id(Uuid::new_v4());
assert!(
hooks
.pre_run_network_instance(&duplicate_cfg)
.await
.is_err()
);
assert_eq!(hooks.tracked_instance_ids(), vec![instance_id]);
let events = events.lock().unwrap();
assert_eq!(events.len(), 1);
let event: Value = serde_json::from_str(&events[0]).unwrap();
assert_eq!(event["event"], "run_network_instance");
assert_eq!(event["success"], true);
assert_eq!(event["instance_id"], instance_id.to_string());
assert!(event["error"].is_null());
INSTANCE_MANAGER
.delete_network_instance(vec![instance_id])
.unwrap();
remove_instance_name_ids(&[instance_id]);
}
#[tokio::test]
async fn config_server_hooks_emit_delete_events_for_tracked_instances() {
let events: Mutex<Vec<String>> = Mutex::new(Vec::new());
let hooks = ManagedConfigServerClientHooks::new(
Some(record_config_server_event),
&events as *const _ as *mut c_void,
);
let instance_id_1 = Uuid::new_v4();
let instance_id_2 = Uuid::new_v4();
let unknown_instance_id = Uuid::new_v4();
for id in [instance_id_1, instance_id_2] {
let cfg = TomlConfigLoader::default();
cfg.set_id(id);
cfg.set_inst_name(format!("test-{}", id));
hooks.pre_run_network_instance(&cfg).await.unwrap();
INSTANCE_MANAGER
.run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG)
.unwrap();
}
hooks
.post_run_network_instance(&instance_id_1)
.await
.unwrap();
hooks
.post_run_network_instance(&instance_id_2)
.await
.unwrap();
events.lock().unwrap().clear();
hooks
.post_remove_network_instances(&[instance_id_1, unknown_instance_id, instance_id_2])
.await
.unwrap();
assert!(hooks.tracked_instance_ids().is_empty());
let events = events.lock().unwrap();
assert_eq!(events.len(), 2);
let event_ids = events
.iter()
.map(|event| {
let event: Value = serde_json::from_str(event).unwrap();
assert_eq!(event["event"], "delete_network_instance");
assert_eq!(event["success"], true);
assert!(event["error"].is_null());
event["instance_id"].as_str().unwrap().to_string()
})
.collect::<HashSet<_>>();
assert_eq!(
event_ids,
HashSet::from([instance_id_1.to_string(), instance_id_2.to_string()])
);
INSTANCE_MANAGER
.delete_network_instance(vec![instance_id_1, instance_id_2])
.unwrap();
remove_instance_name_ids(&[instance_id_1, instance_id_2]);
}
#[tokio::test]
async fn config_server_hooks_remove_untracked_name_mapping_without_event() {
let events: Mutex<Vec<String>> = Mutex::new(Vec::new());
let hooks = ManagedConfigServerClientHooks::new(
Some(record_config_server_event),
&events as *const _ as *mut c_void,
);
let local_id = Uuid::new_v4();
let inst_name = format!("local-{}", local_id);
INSTANCE_NAME_ID_MAP.insert(inst_name.clone(), local_id);
hooks
.post_remove_network_instances(&[local_id])
.await
.unwrap();
assert!(INSTANCE_NAME_ID_MAP.get(&inst_name).is_none());
assert!(events.lock().unwrap().is_empty());
}
#[tokio::test]
async fn config_server_hooks_reject_duplicate_instance_name() {
let hooks = ManagedConfigServerClientHooks::new(None, std::ptr::null_mut());
let inst_name = format!("test-{}", Uuid::new_v4());
let existing_id = Uuid::new_v4();
let new_id = Uuid::new_v4();
INSTANCE_NAME_ID_MAP.insert(inst_name.clone(), existing_id);
let cfg = TomlConfigLoader::default();
cfg.set_inst_name(inst_name.clone());
cfg.set_id(new_id);
assert!(hooks.pre_run_network_instance(&cfg).await.is_err());
assert_eq!(*INSTANCE_NAME_ID_MAP.get(&inst_name).unwrap(), existing_id);
INSTANCE_NAME_ID_MAP.remove(&inst_name);
}
#[tokio::test]
async fn config_server_hooks_remove_overwritten_id_before_duplicate_name_error() {
let events: Mutex<Vec<String>> = Mutex::new(Vec::new());
let hooks = ManagedConfigServerClientHooks::new(
Some(record_config_server_event),
&events as *const _ as *mut c_void,
);
let old_name = format!("old-{}", Uuid::new_v4());
let duplicate_name = format!("duplicate-{}", Uuid::new_v4());
let overwritten_id = Uuid::new_v4();
let duplicate_id = Uuid::new_v4();
hooks.instance_ids.lock().unwrap().insert(overwritten_id);
INSTANCE_NAME_ID_MAP.insert(old_name.clone(), overwritten_id);
INSTANCE_NAME_ID_MAP.insert(duplicate_name.clone(), duplicate_id);
hooks
.post_remove_network_instances(&[overwritten_id])
.await
.unwrap();
let cfg = TomlConfigLoader::default();
cfg.set_inst_name(duplicate_name.clone());
cfg.set_id(overwritten_id);
assert!(hooks.pre_run_network_instance(&cfg).await.is_err());
assert!(hooks.tracked_instance_ids().is_empty());
assert!(INSTANCE_NAME_ID_MAP.get(&old_name).is_none());
assert_eq!(
*INSTANCE_NAME_ID_MAP.get(&duplicate_name).unwrap(),
duplicate_id
);
assert_eq!(events.lock().unwrap().len(), 1);
INSTANCE_NAME_ID_MAP.remove(&duplicate_name);
}
#[tokio::test]
async fn config_server_hooks_remove_tracked_state_before_overwrite_retry() {
let hooks = ManagedConfigServerClientHooks::new(None, std::ptr::null_mut());
let inst_name = format!("test-{}", Uuid::new_v4());
let instance_id = Uuid::new_v4();
hooks.instance_ids.lock().unwrap().insert(instance_id);
INSTANCE_NAME_ID_MAP.insert(inst_name.clone(), instance_id);
let cfg = TomlConfigLoader::default();
cfg.set_inst_name(inst_name.clone());
cfg.set_id(instance_id);
hooks
.post_remove_network_instances(&[instance_id])
.await
.unwrap();
hooks.pre_run_network_instance(&cfg).await.unwrap();
assert!(hooks.tracked_instance_ids().is_empty());
assert!(INSTANCE_NAME_ID_MAP.get(&inst_name).is_none());
}
#[tokio::test]
async fn config_server_hooks_reject_post_run_after_external_delete() {
let hooks = ManagedConfigServerClientHooks::new(None, std::ptr::null_mut());
let instance_id = Uuid::new_v4();
let cfg = TomlConfigLoader::default();
cfg.set_id(instance_id);
cfg.set_inst_name(format!("test-{}", instance_id));
hooks.pre_run_network_instance(&cfg).await.unwrap();
INSTANCE_MANAGER
.run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG)
.unwrap();
INSTANCE_MANAGER
.delete_network_instance(vec![instance_id])
.unwrap();
assert!(hooks.post_run_network_instance(&instance_id).await.is_err());
}
#[test]
fn find_instance_id_by_name_resolves_uncommitted_manager_instance_name() {
let instance_id = Uuid::new_v4();
let inst_name = format!("test-{}", instance_id);
let cfg = TomlConfigLoader::default();
cfg.set_id(instance_id);
cfg.set_inst_name(inst_name.clone());
INSTANCE_MANAGER
.run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG)
.unwrap();
assert_eq!(find_instance_id_by_name(&inst_name), Some(instance_id));
INSTANCE_MANAGER
.delete_network_instance(vec![instance_id])
.unwrap();
remove_instance_name_ids(&[instance_id]);
}
#[test]
fn delete_network_instance_removes_only_named_instances() {
let keep_id = Uuid::new_v4();
let delete_id = Uuid::new_v4();
let keep_name = format!("keep-{}", keep_id);
let delete_name = format!("delete-{}", delete_id);
for (id, name) in [
(keep_id, keep_name.clone()),
(delete_id, delete_name.clone()),
] {
let cfg = TomlConfigLoader::default();
cfg.set_id(id);
cfg.set_inst_name(name.clone());
INSTANCE_MANAGER
.run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG)
.unwrap();
INSTANCE_NAME_ID_MAP.insert(name, id);
}
let delete_name = CString::new(delete_name.clone()).unwrap();
let inst_names = [delete_name.as_ptr()];
assert_eq!(
unsafe { delete_network_instance(inst_names.as_ptr(), inst_names.len()) },
0
);
assert_eq!(find_instance_id_by_name(&keep_name), Some(keep_id));
assert!(find_instance_id_by_name(delete_name.to_str().unwrap()).is_none());
INSTANCE_MANAGER
.delete_network_instance(vec![keep_id])
.unwrap();
remove_instance_name_ids(&[keep_id]);
}
#[test]
fn retain_and_delete_network_instance_reject_invalid_name_pointers() {
assert_eq!(unsafe { retain_network_instance(std::ptr::null(), 1) }, -1);
assert_eq!(unsafe { delete_network_instance(std::ptr::null(), 1) }, -1);
let inst_names = [std::ptr::null()];
assert_eq!(
unsafe { retain_network_instance(inst_names.as_ptr(), inst_names.len()) },
-1
);
assert_eq!(
unsafe { delete_network_instance(inst_names.as_ptr(), inst_names.len()) },
-1
);
}
#[test]
fn ffi_remote_mutation_lock_uses_manager_lock() {
let manager_guard = INSTANCE_MANAGER
.remote_mutation_lock()
.blocking_lock_owned();
let (done_tx, done_rx) = mpsc::channel();
let waiter = std::thread::spawn(move || {
let _ffi_guard = lock_remote_instance_mutation();
done_tx.send(()).unwrap();
});
assert!(done_rx.recv_timeout(Duration::from_millis(100)).is_err());
drop(manager_guard);
done_rx.recv_timeout(Duration::from_secs(5)).unwrap();
waiter.join().unwrap();
}
#[tokio::test]
async fn config_server_hooks_suppress_late_run_events_while_stopping() {
let events: Mutex<Vec<String>> = Mutex::new(Vec::new());
let hooks = ManagedConfigServerClientHooks::new(
Some(record_config_server_event),
&events as *const _ as *mut c_void,
);
hooks.start_stopping();
hooks
.post_run_network_instance(&Uuid::new_v4())
.await
.unwrap();
assert!(hooks.tracked_instance_ids().is_empty());
assert!(events.lock().unwrap().is_empty());
}
#[test]
fn config_server_callback_context_rejects_nested_blocking_ffi_calls() {
let _callback_scope = ConfigServerCallbackScope::enter();
assert_eq!(is_config_server_client_connected(), 0);
let service = CString::new("api.logger.LoggerRpcService").unwrap();
let method = CString::new("get_logger_config").unwrap();
let payload = CString::new("{}").unwrap();
let mut response_ptr: *const c_char = std::ptr::null();
assert_eq!(
unsafe {
call_json_rpc(
service.as_ptr(),
method.as_ptr(),
std::ptr::null(),
payload.as_ptr(),
&mut response_ptr,
)
},
-1
);
assert!(response_ptr.is_null());
assert_eq!(
unsafe { collect_network_infos(std::ptr::null_mut(), 0) },
-1
);
assert_eq!(unsafe { list_instance(std::ptr::null_mut(), 0) }, -1);
let cfg = CString::new("inst_name = \"callback-test\"\nlisteners = []").unwrap();
assert_eq!(unsafe { run_network_instance(cfg.as_ptr()) }, -1);
assert_eq!(unsafe { retain_network_instance(std::ptr::null(), 0) }, -1);
assert_eq!(unsafe { delete_network_instance(std::ptr::null(), 0) }, -1);
let url = CString::new("ring://test/token").unwrap();
let machine_id = CString::new("test-machine").unwrap();
assert_eq!(
unsafe {
start_config_server_client(
url.as_ptr(),
std::ptr::null(),
machine_id.as_ptr(),
false,
None,
std::ptr::null_mut(),
)
},
-1
);
assert_eq!(stop_config_server_client(), -1);
#[cfg(feature = "ffi-dataplane")]
{
assert_eq!(
unsafe {
data_plane_tcp_connect(
std::ptr::null(),
std::ptr::null(),
0,
0,
std::ptr::null_mut(),
std::ptr::null_mut(),
)
},
0
);
assert_eq!(
unsafe {
data_plane_tcp_bind(
std::ptr::null(),
0,
0,
std::ptr::null_mut(),
std::ptr::null_mut(),
)
},
0
);
assert_eq!(
unsafe {
data_plane_tcp_accept(
0,
0,
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
)
},
0
);
assert_eq!(
unsafe { data_plane_tcp_read(0, std::ptr::null_mut(), 0, 0) },
-1
);
assert_eq!(
unsafe { data_plane_tcp_write(0, std::ptr::null(), 0, 0) },
-1
);
assert_eq!(data_plane_tcp_close(0), -1);
assert_eq!(data_plane_tcp_listener_close(0), -1);
assert_eq!(
unsafe {
data_plane_udp_bind(
std::ptr::null(),
0,
0,
std::ptr::null_mut(),
std::ptr::null_mut(),
)
},
0
);
assert_eq!(
unsafe { data_plane_udp_send_to(0, std::ptr::null(), 0, std::ptr::null(), 0, 0) },
-1
);
assert_eq!(
unsafe {
data_plane_udp_recv_from(
0,
std::ptr::null_mut(),
0,
std::ptr::null_mut(),
std::ptr::null_mut(),
0,
)
},
-1
);
assert_eq!(data_plane_udp_close(0), -1);
assert_eq!(data_plane_async_op_status(0), -2);
assert_eq!(data_plane_async_op_wait(0, 0), -2);
assert_eq!(data_plane_async_op_cancel(0), -2);
assert_eq!(data_plane_async_op_free(0), -2);
data_plane_free_bytes(std::ptr::null(), 0);
assert_eq!(
unsafe { data_plane_tcp_connect_start(std::ptr::null(), std::ptr::null(), 0, 0) },
0
);
assert_eq!(
unsafe { data_plane_tcp_bind_start(std::ptr::null(), 0, 0) },
0
);
assert_eq!(unsafe { data_plane_tcp_accept_start(0, 0) }, 0);
assert_eq!(unsafe { data_plane_tcp_read_start(0, 0, 0) }, 0);
assert_eq!(
unsafe { data_plane_tcp_write_start(0, std::ptr::null(), 0, 0) },
0
);
assert_eq!(
unsafe { data_plane_udp_bind_start(std::ptr::null(), 0, 0) },
0
);
assert_eq!(
unsafe { data_plane_udp_send_to_start(0, std::ptr::null(), 0, std::ptr::null(), 0, 0) },
0
);
assert_eq!(unsafe { data_plane_udp_recv_from_start(0, 0, 0) }, 0);
}
}
#[cfg(feature = "ffi-dataplane")]
#[test]
fn active_config_server_rejects_data_plane() {
set_active_for_test(true);
assert_eq!(
unsafe {
data_plane_tcp_connect(
std::ptr::null(),
std::ptr::null(),
0,
0,
std::ptr::null_mut(),
std::ptr::null_mut(),
)
},
0
);
assert_eq!(
unsafe { data_plane_tcp_read(0, std::ptr::null_mut(), 0, 0) },
-1
);
assert_eq!(
unsafe { data_plane_tcp_connect_start(std::ptr::null(), std::ptr::null(), 0, 0) },
0
);
assert_eq!(unsafe { data_plane_tcp_read_start(0, 0, 0) }, 0);
set_active_for_test(false);
}
#[cfg(feature = "ffi-dataplane")]
#[test]
fn async_op_invalid_handle_helpers_are_stable() {
assert_eq!(data_plane_async_op_status(u64::MAX), -2);
assert_eq!(data_plane_async_op_wait(u64::MAX, 1), -2);
assert_eq!(data_plane_async_op_cancel(u64::MAX), -2);
assert_eq!(data_plane_async_op_free(u64::MAX), -2);
data_plane_free_bytes(std::ptr::null(), 0);
}
@@ -0,0 +1,10 @@
use std::ffi::{c_char, c_void};
#[repr(C)]
#[derive(Clone, Copy)]
pub struct KeyValuePair {
pub key: *const c_char,
pub value: *const c_char,
}
pub type ConfigServerEventCallback = Option<unsafe extern "C" fn(*const c_char, *mut c_void)>;
@@ -99,7 +99,7 @@ while true; do
# 启动后的扫尾工作
if pgrep -f "${EASYTIER}" >/dev/null; then
if ! ip rule show | grep -q "lookup main"; then
if ! ip rule show | grep -qE '^[0-9]+:[[:space:]]+from all lookup main$'; then
ip rule add from all lookup main
fi
@@ -109,4 +109,4 @@ while true; do
fi
sleep 10s
done
done
+1 -1
View File
@@ -1,6 +1,6 @@
id=easytier_magisk
name=EasyTier_Magisk
version=v2.6.0
version=v2.6.4
versionCode=1
author=EasyTier
description=easytier magisk module @EasyTier(https://github.com/EasyTier/EasyTier)
+709 -220
View File
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -7,7 +7,10 @@ edition = "2024"
crate-type=["cdylib"]
[dependencies]
ohos-hilog-binding = {version = "*", features = ["redirect"]}
async-trait = "0.1"
base64 = "0.22"
flate2 = "1.1"
gethostname = "1.1"
easytier = { path = "../../easytier" }
napi-derive-ohos = "1.1"
napi-ohos = { version = "1.1", default-features = false, features = [
@@ -26,10 +29,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,777 @@
use crate::config::types::stored_config::{
SnapshotImportResult, StoredConfigList, StoredConfigMeta,
};
use once_cell::sync::Lazy;
use rusqlite::{Connection, OptionalExtension, params};
use std::collections::HashSet;
use std::ops::{Deref, DerefMut};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, MutexGuard};
use std::time::{SystemTime, UNIX_EPOCH};
static CONFIG_DB_PATH: Mutex<Option<PathBuf>> = Mutex::new(None);
static CONFIG_DB_CONNECTION: Lazy<Mutex<Option<CachedConfigDb>>> = Lazy::new(|| Mutex::new(None));
const CONFIG_DB_FILE_NAME: &str = "easytier-config-store.db";
struct CachedConfigDb {
path: PathBuf,
conn: Connection,
}
pub(crate) struct ConfigDbGuard<'a> {
guard: MutexGuard<'a, Option<CachedConfigDb>>,
}
impl Deref for ConfigDbGuard<'_> {
type Target = Connection;
fn deref(&self) -> &Self::Target {
&self
.guard
.as_ref()
.expect("config db connection guard must contain a connection")
.conn
}
}
impl DerefMut for ConfigDbGuard<'_> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self
.guard
.as_mut()
.expect("config db connection guard must contain a connection")
.conn
}
}
#[derive(Debug, Clone)]
struct StoredConfigMetaRecord {
config_id: String,
display_name: String,
created_at: String,
updated_at: String,
favorite: bool,
temporary: bool,
}
type SnapshotFieldRow = (String, String, String, String);
fn snapshot_import_ok() -> SnapshotImportResult {
SnapshotImportResult {
ok: true,
error_code: String::new(),
error_message: String::new(),
snapshot_invalid: false,
}
}
fn snapshot_import_err(
error_code: &str,
error_message: impl Into<String>,
snapshot_invalid: bool,
) -> SnapshotImportResult {
SnapshotImportResult {
ok: false,
error_code: error_code.to_string(),
error_message: error_message.into(),
snapshot_invalid,
}
}
pub(crate) fn now_ts_string() -> String {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.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);",
)?;
ensure_column(
conn,
"stored_configs",
"favorite",
"ALTER TABLE stored_configs ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0;",
)?;
ensure_column(
conn,
"stored_configs",
"temporary",
"ALTER TABLE stored_configs ADD COLUMN temporary INTEGER NOT NULL DEFAULT 0;",
)?;
ensure_column(
conn,
"stored_config_fields",
"updated_at",
"ALTER TABLE stored_config_fields ADD COLUMN updated_at TEXT NOT NULL DEFAULT '0';",
)?;
if !validate_store_schema(conn)? {
return Err(rusqlite::Error::InvalidQuery);
}
conn.execute_batch("PRAGMA user_version = 1;")
}
fn table_columns(conn: &Connection, table_name: &str) -> rusqlite::Result<HashSet<String>> {
let mut stmt = conn.prepare(&format!("PRAGMA table_info({})", table_name))?;
let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
let mut columns = HashSet::new();
for row in rows {
columns.insert(row?);
}
Ok(columns)
}
fn ensure_column(
conn: &Connection,
table_name: &str,
column_name: &str,
alter_sql: &str,
) -> rusqlite::Result<()> {
let columns = table_columns(conn, table_name)?;
if !columns.contains(column_name) {
conn.execute_batch(alter_sql)?;
}
Ok(())
}
fn validate_store_schema(conn: &Connection) -> rusqlite::Result<bool> {
let meta_columns = table_columns(conn, "stored_configs")?;
let field_columns = table_columns(conn, "stored_config_fields")?;
let required_meta = [
"config_id",
"display_name",
"created_at",
"updated_at",
"favorite",
"temporary",
];
let required_fields = ["config_id", "field_name", "field_json", "updated_at"];
Ok(required_meta
.iter()
.all(|column| meta_columns.contains(*column))
&& required_fields
.iter()
.all(|column| field_columns.contains(*column)))
}
fn move_db_file_if_exists(path: &Path) -> bool {
if !path.exists() {
return true;
}
let target = PathBuf::from(format!(
"{}.corrupt.{}",
path.to_string_lossy(),
now_ts_string()
));
match std::fs::rename(path, &target) {
Ok(_) => true,
Err(e) => {
ohrs_log_error!(
"[Rust] failed to move corrupt config db {} to {}: {}",
path.display(),
target.display(),
e
);
false
}
}
}
fn recover_config_db_files(path: &Path) -> bool {
let main_ok = move_db_file_if_exists(path);
let wal_ok = move_db_file_if_exists(Path::new(&format!("{}-wal", path.to_string_lossy())));
let shm_ok = move_db_file_if_exists(Path::new(&format!("{}-shm", path.to_string_lossy())));
main_ok && wal_ok && shm_ok
}
fn open_connection(path: &Path) -> Option<Connection> {
let conn = match Connection::open(path) {
Ok(conn) => conn,
Err(e) => {
ohrs_log_error!("[Rust] failed to open config db {}: {}", path.display(), e);
return None;
}
};
if let Err(e) = init_schema(&conn) {
ohrs_log_error!(
"[Rust] failed to initialize config db {}: {}",
path.display(),
e
);
drop(conn);
if !recover_config_db_files(path) {
return None;
}
let recovered = match Connection::open(path) {
Ok(conn) => conn,
Err(e) => {
ohrs_log_error!(
"[Rust] failed to open recovered config db {}: {}",
path.display(),
e
);
return None;
}
};
if let Err(e) = init_schema(&recovered) {
ohrs_log_error!(
"[Rust] failed to initialize recovered config db {}: {}",
path.display(),
e
);
return None;
}
return Some(recovered);
}
Some(conn)
}
pub(crate) fn open_db() -> Option<ConfigDbGuard<'static>> {
let path = db_file_path()?;
let mut guard = match CONFIG_DB_CONNECTION.lock() {
Ok(guard) => guard,
Err(e) => {
ohrs_log_error!("[Rust] failed to lock config db connection: {}", e);
return None;
}
};
let should_open = guard
.as_ref()
.map(|cached| cached.path != path || !cached.path.exists())
.unwrap_or(true);
if should_open {
let conn = open_connection(&path)?;
*guard = Some(CachedConfigDb { path, conn });
}
Some(ConfigDbGuard { guard })
}
fn row_to_meta(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoredConfigMetaRecord> {
Ok(StoredConfigMetaRecord {
config_id: row.get(0)?,
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 validate_snapshot_schema(conn: &Connection) -> bool {
let has_stored_configs = conn
.query_row(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'stored_configs'",
[],
|row| row.get::<_, i64>(0),
)
.optional()
.ok()
.flatten()
.is_some();
let has_stored_fields = conn
.query_row(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'stored_config_fields'",
[],
|row| row.get::<_, i64>(0),
)
.optional()
.ok()
.flatten()
.is_some();
has_stored_configs && has_stored_fields
}
fn read_snapshot_tables(
src: &Connection,
) -> rusqlite::Result<(Vec<StoredConfigMetaRecord>, Vec<SnapshotFieldRow>)> {
src.execute_batch("BEGIN DEFERRED TRANSACTION")?;
let mut meta_rows = Vec::<StoredConfigMetaRecord>::new();
let mut field_rows = Vec::<SnapshotFieldRow>::new();
let read_result = (|| -> rusqlite::Result<()> {
{
let mut stmt = src.prepare(
"SELECT config_id, display_name, created_at, updated_at, favorite, temporary
FROM stored_configs",
)?;
let rows = stmt.query_map([], row_to_meta)?;
for row in rows {
meta_rows.push(row?);
}
}
{
let mut stmt = src.prepare(
"SELECT config_id, field_name, field_json, updated_at
FROM stored_config_fields",
)?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
})?;
for row in rows {
field_rows.push(row?);
}
}
Ok(())
})();
match read_result {
Ok(()) => {
src.execute_batch("COMMIT")?;
Ok((meta_rows, field_rows))
}
Err(err) => {
let _ = src.execute_batch("ROLLBACK");
Err(err)
}
}
}
fn write_snapshot_tables(
dst: &mut Connection,
meta_rows: Vec<StoredConfigMetaRecord>,
field_rows: Vec<SnapshotFieldRow>,
) -> rusqlite::Result<()> {
let tx = dst.unchecked_transaction()?;
tx.execute("DELETE FROM stored_config_fields", [])?;
tx.execute("DELETE FROM stored_configs", [])?;
for row in meta_rows {
tx.execute(
"INSERT INTO stored_configs (
config_id, display_name, created_at, updated_at, favorite, temporary
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
row.config_id,
row.display_name,
row.created_at,
row.updated_at,
if row.favorite { 1 } else { 0 },
if row.temporary { 1 } else { 0 }
],
)?;
}
for (config_id, field_name, field_json, updated_at) in field_rows {
tx.execute(
"INSERT INTO stored_config_fields (config_id, field_name, field_json, updated_at)
VALUES (?1, ?2, ?3, ?4)",
params![config_id, field_name, field_json, updated_at],
)?;
}
tx.commit()
}
fn copy_snapshot_tables(src: &Connection, dst: &mut Connection) -> rusqlite::Result<()> {
let (meta_rows, field_rows) = read_snapshot_tables(src)?;
write_snapshot_tables(dst, meta_rows, field_rows)
}
fn ensure_parent_dir(path: &Path) -> bool {
match path.parent() {
Some(parent) => match std::fs::create_dir_all(parent) {
Ok(_) => true,
Err(e) => {
ohrs_log_error!(
"[Rust] failed to create snapshot parent {}: {}",
parent.display(),
e
);
false
}
},
None => true,
}
}
fn to_meta(record: StoredConfigMetaRecord) -> StoredConfigMeta {
StoredConfigMeta {
config_id: record.config_id,
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) {
ohrs_log_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) => {
ohrs_log_error!("[Rust] failed to lock config db path: {}", e);
return false;
}
}
if open_db().is_none() {
return false;
}
ohrs_log_debug!("[Rust] initialized config db at {}", db_path.display());
true
}
pub fn export_config_store_snapshot(target_path: String) -> bool {
let target = PathBuf::from(target_path);
if !ensure_parent_dir(&target) {
return false;
}
let Some(src) = open_db() else {
return false;
};
let mut dst = match Connection::open(&target) {
Ok(conn) => conn,
Err(e) => {
ohrs_log_error!(
"[Rust] failed to open snapshot target {}: {}",
target.display(),
e
);
return false;
}
};
if let Err(e) = init_schema(&dst) {
ohrs_log_error!(
"[Rust] failed to init snapshot schema {}: {}",
target.display(),
e
);
return false;
}
match copy_snapshot_tables(&src, &mut dst) {
Ok(_) => true,
Err(e) => {
ohrs_log_error!(
"[Rust] failed to export snapshot {}: {}",
target.display(),
e
);
false
}
}
}
pub fn import_config_store_snapshot_with_result(source_path: String) -> SnapshotImportResult {
let source = PathBuf::from(source_path);
let src = match Connection::open(&source) {
Ok(conn) => conn,
Err(e) => {
ohrs_log_error!(
"[Rust] failed to open snapshot source {}: {}",
source.display(),
e
);
return snapshot_import_err("source_open_failed", e.to_string(), false);
}
};
if !validate_snapshot_schema(&src) {
ohrs_log_error!("[Rust] invalid snapshot schema {}", source.display());
return snapshot_import_err(
"invalid_snapshot_schema",
format!("invalid snapshot schema: {}", source.display()),
true,
);
}
let (meta_rows, field_rows) = match read_snapshot_tables(&src) {
Ok(rows) => rows,
Err(e) => {
ohrs_log_error!(
"[Rust] failed to read snapshot source {}: {}",
source.display(),
e
);
return snapshot_import_err("invalid_snapshot_data", e.to_string(), true);
}
};
let Some(mut dst) = open_db() else {
return snapshot_import_err(
"destination_open_failed",
"failed to open local config store",
false,
);
};
match write_snapshot_tables(&mut dst, meta_rows, field_rows) {
Ok(_) => snapshot_import_ok(),
Err(e) => {
ohrs_log_error!(
"[Rust] failed to import snapshot {}: {}",
source.display(),
e
);
snapshot_import_err("destination_write_failed", e.to_string(), false)
}
}
}
pub fn import_config_store_snapshot(source_path: String) -> bool {
import_config_store_snapshot_with_result(source_path).ok
}
pub fn reset_config_meta_store() -> bool {
let Some(conn) = open_db() else {
return false;
};
let tx = match conn.unchecked_transaction() {
Ok(tx) => tx,
Err(e) => {
ohrs_log_error!(
"[Rust] failed to start config store reset transaction: {}",
e
);
return false;
}
};
if let Err(e) = tx.execute("DELETE FROM stored_config_fields", []) {
ohrs_log_error!("[Rust] failed to reset config fields: {}", e);
let _ = tx.rollback();
return false;
}
if let Err(e) = tx.execute("DELETE FROM stored_configs", []) {
ohrs_log_error!("[Rust] failed to reset config meta: {}", e);
let _ = tx.rollback();
return false;
}
match tx.commit() {
Ok(_) => true,
Err(e) => {
ohrs_log_error!("[Rust] failed to commit config store reset: {}", e);
false
}
}
}
pub fn list_config_meta_entries() -> StoredConfigList {
let Some(conn) = open_db() else {
return StoredConfigList { configs: vec![] };
};
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) => {
ohrs_log_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) => {
ohrs_log_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(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 set_config_favorite(config_id: String, favorite: bool) -> Option<StoredConfigMeta> {
let conn = open_db()?;
let now = now_ts_string();
let tx = conn.unchecked_transaction().ok()?;
if favorite {
tx.execute(
"UPDATE stored_configs
SET favorite = 0,
updated_at = CASE WHEN favorite != 0 THEN ?1 ELSE updated_at END
WHERE favorite != 0 AND config_id <> ?2",
params![now, config_id.clone()],
)
.ok()?;
}
let rows = tx
.execute(
"UPDATE stored_configs
SET favorite = ?2, updated_at = ?3
WHERE config_id = ?1",
params![config_id.clone(), if favorite { 1 } else { 0 }, now],
)
.ok()?;
if rows == 0 {
return None;
}
let meta = tx
.query_row(
"SELECT config_id, display_name, created_at, updated_at, favorite, temporary
FROM stored_configs WHERE config_id = ?1",
params![config_id],
row_to_meta,
)
.optional()
.ok()
.flatten()
.map(to_meta)?;
tx.commit().ok()?;
Some(meta)
}
@@ -0,0 +1 @@
pub(crate) mod config_meta;
@@ -0,0 +1 @@
pub(crate) mod stored_config;
@@ -0,0 +1,70 @@
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 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,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
#[napi(object)]
pub struct SnapshotImportResult {
pub ok: bool,
pub error_code: String,
pub error_message: String,
pub snapshot_invalid: bool,
}
@@ -0,0 +1,457 @@
use super::{field_store, import_export, legacy_migration, validation};
use crate::config::storage::config_meta::{
get_config_meta, init_config_meta_store, list_config_meta_entries, open_db,
reset_config_meta_store, upsert_config_meta_in_tx,
};
use crate::config::types::stored_config::{ExportTomlResult, StoredConfigRecord};
use easytier::proto::api::manage::NetworkConfig;
use once_cell::sync::Lazy;
use rusqlite::params;
use serde_json::Value;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::Instant;
static CONFIG_ROOT_DIR: Mutex<Option<PathBuf>> = Mutex::new(None);
static RUNTIME_CONFIG_SNAPSHOTS: Lazy<Mutex<HashMap<String, RuntimeConfigSnapshot>>> =
Lazy::new(|| Mutex::new(HashMap::new()));
pub(crate) const CONFIG_DIR_NAME: &str = "easytier-configs";
pub(crate) const KERNEL_SOCKET_FILE_NAME: &str = "easytier-kernel.sock";
#[derive(Clone)]
pub(crate) struct RuntimeConfigSnapshot {
pub display_name: String,
pub config: NetworkConfig,
}
pub(crate) fn cache_runtime_config_snapshot(
config_id: String,
display_name: String,
config: NetworkConfig,
) {
if let Ok(mut guard) = RUNTIME_CONFIG_SNAPSHOTS.lock() {
guard.insert(
config_id,
RuntimeConfigSnapshot {
display_name,
config,
},
);
}
}
pub(crate) fn clear_runtime_config_snapshot(config_id: &str) {
if let Ok(mut guard) = RUNTIME_CONFIG_SNAPSHOTS.lock() {
guard.remove(config_id);
}
}
pub(crate) fn get_runtime_config_snapshot(config_id: &str) -> Option<RuntimeConfigSnapshot> {
RUNTIME_CONFIG_SNAPSHOTS
.lock()
.ok()
.and_then(|guard| guard.get(config_id).cloned())
}
pub(crate) fn get_runtime_config_route_overrides(config_id: &str) -> (Vec<String>, Vec<String>) {
RUNTIME_CONFIG_SNAPSHOTS
.lock()
.ok()
.and_then(|guard| {
guard.get(config_id).map(|snapshot| {
(
snapshot.config.routes.clone(),
snapshot.config.proxy_cidrs.clone(),
)
})
})
.unwrap_or_default()
}
pub(crate) fn config_root_dir() -> Option<PathBuf> {
CONFIG_ROOT_DIR
.lock()
.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) {
ohrs_log_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) => {
ohrs_log_error!("[Rust] failed to lock config root dir: {}", e);
return false;
}
}
if !init_config_meta_store(root.to_string_lossy().into_owned()) {
return false;
}
ohrs_log_debug!(
"[Rust] initialized config repo at {}",
configs_dir.display()
);
true
}
pub fn reset_config_store() -> bool {
if !reset_config_meta_store() {
return false;
}
if let Ok(mut guard) = RUNTIME_CONFIG_SNAPSHOTS.lock() {
guard.clear();
}
true
}
fn migrate_legacy_file_if_needed(config_id: &str) -> Option<()> {
if validation::validate_config_id(config_id).is_err() {
return None;
}
legacy_migration::migrate_legacy_file_if_needed(
&config_root_dir(),
CONFIG_DIR_NAME,
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) => {
ohrs_log_error!("[Rust] save_config_record failed {}", e);
return None;
}
};
let normalized_json = match serde_json::to_string(&config) {
Ok(raw) => raw,
Err(e) => {
ohrs_log_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 = tx
.query_row(
"SELECT favorite, temporary FROM stored_configs WHERE config_id = ?1",
params![config_id.clone()],
|row| Ok((row.get::<_, i64>(0)? != 0, row.get::<_, i64>(1)? != 0)),
)
.ok();
let favorite = existing_meta.map(|meta| meta.0).unwrap_or(false);
let temporary = existing_meta.map(|meta| meta.1).unwrap_or(false);
let 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> {
validation::validate_config_id(config_id).ok()?;
migrate_legacy_file_if_needed(config_id)?;
let object = field_store::load_config_map_from_db(config_id)?;
serde_json::to_string(&Value::Object(object)).ok()
}
pub fn get_config_record(config_id: &str) -> Option<StoredConfigRecord> {
validation::validate_config_id(config_id).ok()?;
let config_json = load_config_json(config_id)?;
let meta = get_config_meta(config_id)?;
Some(StoredConfigRecord { meta, config_json })
}
pub fn get_config_field_value(config_id: &str, field: &str) -> Option<String> {
let total_start = Instant::now();
validation::validate_config_id(config_id).ok()?;
migrate_legacy_file_if_needed(config_id)?;
let open_start = Instant::now();
let conn = open_db()?;
let open_elapsed = open_start.elapsed();
let query_start = Instant::now();
let result = conn
.query_row(
"SELECT field_json FROM stored_config_fields
WHERE config_id = ?1 AND field_name = ?2",
params![config_id, field],
|row| row.get::<_, String>(0),
)
.ok();
ohrs_log_debug!(
"[Rust] get_config_field_value config={} field={} found={} open_ms={} query_ms={} total_ms={} len={}",
config_id,
field,
result.is_some(),
open_elapsed.as_millis(),
query_start.elapsed().as_millis(),
total_start.elapsed().as_millis(),
result.as_ref().map(|value| value.len()).unwrap_or(0)
);
result
}
pub fn set_config_field_value(config_id: &str, field: &str, json_value: &str) -> bool {
if validation::validate_config_id(config_id).is_err() {
return false;
}
if field.contains('.') {
return false;
}
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_default_config_json() -> Option<String> {
crate::build_default_network_config_json().ok()
}
pub fn create_config_record(config_id: String, display_name: String) -> Option<StoredConfigRecord> {
validation::validate_config_id(&config_id).ok()?;
let raw = get_default_config_json()?;
let mut config = serde_json::from_str::<NetworkConfig>(&raw).ok()?;
config.instance_id = Some(config_id.clone());
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 {
if validation::validate_config_id(config_id).is_err() {
return false;
}
let raw = match load_config_json(config_id) {
Some(raw) => raw,
None => return false,
};
let display_name = get_config_meta(config_id)
.map(|meta| meta.display_name)
.unwrap_or_else(|| config_id.to_string());
let started = crate::run_network_instance_from_json(&raw);
if started && let Ok(config) = serde_json::from_str::<NetworkConfig>(&raw) {
cache_runtime_config_snapshot(config_id.to_string(), display_name, config);
}
started
}
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 validation::validate_config_id(config_id).is_err() {
return false;
}
if let Some(path) = legacy_config_file_path(config_id) {
if path.exists() {
let _ = std::fs::remove_file(path);
}
}
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],
) {
ohrs_log_error!("[Rust] failed to delete config fields {}: {}", config_id, e);
return false;
}
match conn.execute(
"DELETE FROM stored_configs WHERE config_id = ?1",
params![config_id],
) {
Ok(rows) => rows > 0,
Err(e) => {
ohrs_log_error!("[Rust] failed to delete config meta {}: {}", config_id, e);
false
}
}
}
pub fn export_config_toml(config_id: &str) -> Option<ExportTomlResult> {
validation::validate_config_id(config_id).ok()?;
let record = get_config_record(config_id)?;
import_export::export_config_toml_from_record(&record)
}
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,66 @@
use crate::config::storage::config_meta::{now_ts_string, open_db};
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],
) {
ohrs_log_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()],
) {
ohrs_log_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,50 @@
use crate::config::storage::config_meta::get_config_meta;
use std::path::PathBuf;
use super::validation;
pub(super) fn legacy_config_file_path(
root_dir: &Option<PathBuf>,
config_dir_name: &str,
config_id: &str,
) -> Option<PathBuf> {
if !validation::is_valid_config_id(config_id) {
ohrs_log_error!("[Rust] invalid legacy config_id {}", config_id);
return None;
}
root_dir.as_ref().map(|root| {
root.join(config_dir_name)
.join(format!("{}.json", config_id))
})
}
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) {
ohrs_log_error!(
"[Rust] failed to remove legacy config file {}: {}",
legacy_path.display(),
e
);
}
Some(())
}
@@ -0,0 +1,42 @@
use easytier::proto::api::manage::NetworkConfig;
use serde_json::{Map, Value};
use uuid::Uuid;
pub(super) fn validate_config_id(config_id: &str) -> Result<(), String> {
if config_id.is_empty() {
return Err("config_id is required".to_string());
}
Uuid::parse_str(config_id)
.map(|_| ())
.map_err(|e| format!("invalid config_id {}: {}", config_id, e))
}
pub(super) fn is_valid_config_id(config_id: &str) -> bool {
validate_config_id(config_id).is_ok()
}
pub(super) fn normalize_config_id(
mut config: NetworkConfig,
requested_id: String,
) -> Result<NetworkConfig, String> {
validate_config_id(&requested_id)?;
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,69 @@
use crate::config;
use crate::config::types::stored_config::SnapshotImportResult;
pub(crate) fn init_config_store(root_dir: String) -> bool {
config::repository::init_config_store(root_dir)
}
pub(crate) fn reset_config_store() -> bool {
config::repository::reset_config_store()
}
pub(crate) fn list_configs() -> String {
config::repository::list_config_meta_json()
}
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 set_config_favorite(config_id: String, favorite: bool) -> bool {
config::storage::config_meta::set_config_favorite(config_id, favorite).is_some()
}
pub(crate) fn import_toml(toml_text: String, display_name: Option<String>) -> Option<String> {
config::repository::import_toml_config(toml_text, display_name)
.map(|record| record.meta.config_id)
}
pub(crate) fn export_toml(config_id: String) -> Option<String> {
config::repository::export_config_toml(&config_id).map(|ret| ret.toml_text)
}
pub(crate) fn export_config_store_snapshot(target_path: String) -> bool {
config::storage::config_meta::export_config_store_snapshot(target_path)
}
pub(crate) fn import_config_store_snapshot(source_path: String) -> bool {
config::storage::config_meta::import_config_store_snapshot(source_path)
}
pub(crate) fn import_config_store_snapshot_with_result(
source_path: String,
) -> SnapshotImportResult {
config::storage::config_meta::import_config_store_snapshot_with_result(source_path)
}
@@ -0,0 +1,232 @@
use crate::config::repository::{clear_runtime_config_snapshot, get_runtime_config_snapshot};
use crate::config::types::stored_config::KeyValuePair;
use crate::kernel_bridge::{
aggregate_requested_tun_routes, start_local_socket_server as start_local_socket_server_inner,
stop_local_socket_server as stop_local_socket_server_inner,
};
use crate::runtime::state::runtime_state::{
RuntimeAggregateState, RuntimeInstanceState, TunAggregateState, clear_tun_attached,
is_tun_attached, mark_tun_attached, runtime_instance_from_config_snapshot,
runtime_instance_from_running_info,
};
use crate::{ASYNC_RUNTIME, INSTANCE_MANAGER, WEB_CLIENTS};
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) {
clear_runtime_config_snapshot(&config_id);
return true;
}
let _ = stop_local_socket_server_inner();
let Some(instance_id) = parse_instance_uuid(&config_id) else {
return false;
};
let ret = INSTANCE_MANAGER
.delete_network_instance(vec![instance_id])
.map(|_| true)
.unwrap_or_else(|err| {
ohrs_log_error!("[Rust] stop_kernel failed {}: {}", config_id, err);
false
});
if ret {
clear_runtime_config_snapshot(&config_id);
}
let has_active_instances = !INSTANCE_MANAGER.list_network_instance_ids().is_empty();
let has_web_clients = WEB_CLIENTS
.lock()
.map(|guard| !guard.is_empty())
.unwrap_or(false);
if has_active_instances || has_web_clients {
let _ = start_local_socket_server_inner();
}
maybe_stop_local_socket_server();
ret
}
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 ASYNC_RUNTIME.block_on(INSTANCE_MANAGER.collect_network_infos()) {
Ok(infos) => infos,
Err(err) => {
ohrs_log_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 {
ohrs_log_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);
ohrs_log_info!(
"[Rust] set_tun_fd success instance={} fd={} marked_attached=true",
config_id,
fd
);
true
})
.unwrap_or_else(|err| {
ohrs_log_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 ASYNC_RUNTIME.block_on(INSTANCE_MANAGER.collect_network_infos()) {
Ok(infos) => infos,
Err(err) => {
ohrs_log_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 live_infos = infos
.into_iter()
.map(|(instance_id, info)| (instance_id.to_string(), info))
.collect::<std::collections::HashMap<_, _>>();
let mut active_config_ids = live_infos.keys().cloned().collect::<Vec<_>>();
if let Ok(guard) = WEB_CLIENTS.lock() {
for config_id in guard.keys() {
if !active_config_ids.iter().any(|value| value == config_id) {
active_config_ids.push(config_id.clone());
}
}
}
let mut instances = Vec::with_capacity(active_config_ids.len());
for config_id in active_config_ids {
if let Some(info) = live_infos.remove(&config_id) {
let snapshot = get_runtime_config_snapshot(&config_id);
let display_name = snapshot
.as_ref()
.map(|snapshot| snapshot.display_name.clone())
.unwrap_or_else(|| config_id.clone());
let magic_dns_enabled = snapshot
.as_ref()
.and_then(|snapshot| snapshot.config.enable_magic_dns)
.unwrap_or(false);
let need_exit_node = snapshot
.as_ref()
.map(|snapshot| !snapshot.config.exit_nodes.is_empty())
.unwrap_or(false);
instances.push(runtime_instance_from_running_info(
config_id,
display_name,
magic_dns_enabled,
need_exit_node,
info,
));
} else if let Some(snapshot) = get_runtime_config_snapshot(&config_id) {
instances.push(runtime_instance_from_config_snapshot(
config_id,
snapshot.display_name,
snapshot.config,
true,
));
} else {
let tun_attached = is_tun_attached(&config_id);
instances.push(RuntimeInstanceState {
config_id: config_id.clone(),
instance_id: config_id.clone(),
display_name: config_id.clone(),
running: true,
tun_required: tun_attached,
tun_attached,
magic_dns_enabled: false,
need_exit_node: false,
error_message: None,
my_node_info: None,
events: Vec::new(),
routes: Vec::new(),
peers: Vec::new(),
});
}
}
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,8 @@
mod protocol;
mod routing;
mod socket_server;
pub(crate) use routing::aggregate_requested_tun_routes;
pub use socket_server::{
set_snapshot_broadcast_enabled, start_local_socket_server, stop_local_socket_server,
};
@@ -0,0 +1,84 @@
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
}
pub(crate) fn send_local_socket_json_payload_message(
stream: &mut UnixStream,
message_type: &str,
payload_json: &str,
) -> std::io::Result<()> {
let message_type_json = serde_json::to_string(message_type)
.map_err(|err| Error::new(ErrorKind::InvalidData, err.to_string()))?;
let mut raw = Vec::with_capacity(message_type_json.len() + payload_json.len() + 38);
raw.extend_from_slice(b"{\"messageType\":");
raw.extend_from_slice(message_type_json.as_bytes());
raw.extend_from_slice(b",\"payloadJson\":");
raw.extend_from_slice(payload_json.as_bytes());
raw.extend_from_slice(b"}\n");
stream.write_all(&raw)?;
Ok(())
}
pub(crate) fn broadcast_local_socket_json_payload_message(
clients: &mut Vec<UnixStream>,
message_type: &str,
payload_json: &str,
) -> bool {
let mut active_clients = Vec::with_capacity(clients.len());
let mut delivered = false;
for mut client in clients.drain(..) {
if send_local_socket_json_payload_message(&mut client, message_type, payload_json).is_ok() {
delivered = true;
active_clients.push(client);
}
}
*clients = active_clients;
delivered
}
@@ -0,0 +1,92 @@
use crate::config::repository::get_runtime_config_route_overrides;
use crate::runtime::state::runtime_state::RuntimeInstanceState;
use ipnet::IpNet;
use std::collections::HashSet;
use std::net::IpAddr;
fn normalize_route_cidr(route: &str) -> Option<String> {
let normalized = route.split("->").next().unwrap_or(route).trim();
normalized
.parse::<IpNet>()
.ok()
.map(|network| match network {
IpNet::V4(net) => net.trunc().to_string(),
IpNet::V6(net) => net.trunc().to_string(),
})
.or_else(|| {
normalized.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, config_proxy_cidrs) =
get_runtime_config_route_overrides(&instance.config_id);
let runtime_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(config_proxy_cidrs.iter().cloned());
raw_routes.extend(runtime_proxy_cidrs.iter().cloned());
simplify_routes(raw_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,332 @@
use super::protocol::{
TunRequestPayload, broadcast_local_socket_json_payload_message, broadcast_local_socket_message,
};
use crate::INSTANCE_MANAGER;
use crate::config::repository::kernel_socket_path;
use crate::get_runtime_snapshot_inner;
use crate::kernel_bridge::routing::aggregate_tun_routes;
use easytier::common::global_ctx::{EventBusSubscriber, GlobalCtxEvent};
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, Instant};
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));
static SNAPSHOT_BROADCAST_ENABLED: AtomicBool = AtomicBool::new(true);
const SOCKET_TICK_INTERVAL: Duration = Duration::from_millis(250);
const TUN_FAST_CHECK_WINDOW: Duration = Duration::from_secs(8);
const EVENT_RECEIVER_SYNC_INTERVAL: Duration = Duration::from_secs(1);
pub fn set_snapshot_broadcast_enabled(enabled: bool) {
SNAPSHOT_BROADCAST_ENABLED.store(enabled, Ordering::Relaxed);
}
fn sync_tun_event_receivers(receivers: &mut HashMap<String, EventBusSubscriber>) {
let mut active_instance_ids = HashSet::new();
for instance in INSTANCE_MANAGER.iter() {
let instance_id = instance.key().to_string();
active_instance_ids.insert(instance_id.clone());
if !receivers.contains_key(&instance_id)
&& let Some(receiver) = instance.value().subscribe_event()
{
receivers.insert(instance_id, receiver);
}
}
receivers.retain(|instance_id, _| active_instance_ids.contains(instance_id));
}
fn event_needs_tun_refresh(event: &GlobalCtxEvent) -> bool {
matches!(
event,
GlobalCtxEvent::DhcpIpv4Changed(_, _)
| GlobalCtxEvent::DhcpIpv4Conflicted(_)
| GlobalCtxEvent::PublicIpv6Changed(_, _)
| GlobalCtxEvent::PublicIpv6RoutesUpdated(_, _)
| GlobalCtxEvent::ProxyCidrsUpdated(_, _)
| GlobalCtxEvent::ConfigPatched(_)
| GlobalCtxEvent::PeerAdded(_)
| GlobalCtxEvent::PeerRemoved(_)
| GlobalCtxEvent::PeerConnAdded(_)
| GlobalCtxEvent::PeerConnRemoved(_)
)
}
fn drain_tun_refresh_events(receivers: &mut HashMap<String, EventBusSubscriber>) -> bool {
let mut refresh_needed = false;
let mut closed_receivers = Vec::new();
for (instance_id, receiver) in receivers.iter_mut() {
loop {
match receiver.try_recv() {
Ok(event) => {
refresh_needed = event_needs_tun_refresh(&event) || refresh_needed;
}
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => break,
Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => {
refresh_needed = true;
continue;
}
Err(tokio::sync::broadcast::error::TryRecvError::Closed) => {
closed_receivers.push(instance_id.clone());
break;
}
}
}
}
for instance_id in closed_receivers {
receivers.remove(&instance_id);
}
refresh_needed
}
pub fn start_local_socket_server() -> bool {
let socket_path = match kernel_socket_path() {
Some(path) => path,
None => {
ohrs_log_error!("[Rust] kernel socket path unavailable");
return false;
}
};
match LOCAL_SOCKET_STATE.lock() {
Ok(guard) if guard.is_some() => return true,
Ok(_) => {}
Err(err) => {
ohrs_log_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) => {
ohrs_log_error!(
"[Rust] bind localsocket failed {}: {}",
socket_path.display(),
err
);
return false;
}
};
if let Err(err) = listener.set_nonblocking(true) {
ohrs_log_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 tun_fast_until = Instant::now() + TUN_FAST_CHECK_WINDOW;
let mut tun_bootstrap_done = false;
let mut last_event_receiver_sync_at: Option<Instant> = None;
let mut tun_event_receivers = HashMap::<String, EventBusSubscriber>::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);
tun_fast_until = Instant::now() + TUN_FAST_CHECK_WINDOW;
tun_bootstrap_done = false;
}
Err(err) if err.kind() == ErrorKind::WouldBlock => break,
Err(err) => {
ohrs_log_error!("[Rust] accept localsocket failed: {}", err);
break;
}
}
}
let snapshot_enabled = SNAPSHOT_BROADCAST_ENABLED.load(Ordering::Relaxed);
if clients.is_empty() {
if !last_snapshot_json.is_empty() {
last_snapshot_json.clear();
}
delivered_tun_requests.clear();
last_tun_route_signatures.clear();
tun_event_receivers.clear();
last_event_receiver_sync_at = None;
tun_bootstrap_done = false;
thread::sleep(SOCKET_TICK_INTERVAL);
continue;
}
let now = Instant::now();
let should_sync_event_receivers = accepted_client
|| last_event_receiver_sync_at
.map(|last| now.duration_since(last) >= EVENT_RECEIVER_SYNC_INTERVAL)
.unwrap_or(true);
if should_sync_event_receivers {
sync_tun_event_receivers(&mut tun_event_receivers);
last_event_receiver_sync_at = Some(now);
}
if drain_tun_refresh_events(&mut tun_event_receivers) {
tun_bootstrap_done = false;
tun_fast_until = now + TUN_FAST_CHECK_WINDOW;
}
let should_collect_snapshot = snapshot_enabled
|| accepted_client
|| (!tun_bootstrap_done && now < tun_fast_until);
if !should_collect_snapshot {
if !last_snapshot_json.is_empty() {
last_snapshot_json.clear();
}
thread::sleep(SOCKET_TICK_INTERVAL);
continue;
}
let snapshot = get_runtime_snapshot_inner();
if snapshot_enabled {
let snapshot_json = match serde_json::to_string(&snapshot) {
Ok(json) => json,
Err(err) => {
ohrs_log_error!("[Rust] serialize runtime snapshot failed: {}", err);
thread::sleep(SOCKET_TICK_INTERVAL);
continue;
}
};
if accepted_client || snapshot_json != last_snapshot_json {
let _ = broadcast_local_socket_json_payload_message(
&mut clients,
"runtime_snapshot",
&snapshot_json,
);
last_snapshot_json = snapshot_json;
}
} else if !last_snapshot_json.is_empty() {
last_snapshot_json.clear();
}
let mut saw_running_instance = false;
let mut saw_tun_candidate = false;
for instance in snapshot.instances.iter() {
if instance.running {
saw_running_instance = true;
}
if instance.running && instance.tun_required {
saw_tun_candidate = true;
let virtual_ipv4 = instance
.my_node_info
.as_ref()
.and_then(|info| info.virtual_ipv4.clone());
let virtual_ipv4_cidr = instance
.my_node_info
.as_ref()
.and_then(|info| info.virtual_ipv4_cidr.clone());
if clients.is_empty() {
continue;
}
if virtual_ipv4.is_none() || virtual_ipv4_cidr.is_none() {
continue;
}
let aggregated_routes = aggregate_tun_routes(instance);
let route_signature = serde_json::to_string(&(
&virtual_ipv4,
&virtual_ipv4_cidr,
&aggregated_routes,
instance.magic_dns_enabled,
instance.need_exit_node,
))
.unwrap_or_else(|_| "[]".to_string());
let should_send = accepted_client
|| !delivered_tun_requests.contains(&instance.instance_id)
|| last_tun_route_signatures
.get(&instance.instance_id)
.map(|value| value != &route_signature)
.unwrap_or(true);
if !should_send {
continue;
}
let payload = TunRequestPayload {
config_id: instance.config_id.clone(),
instance_id: instance.instance_id.clone(),
display_name: instance.display_name.clone(),
virtual_ipv4,
virtual_ipv4_cidr,
aggregated_routes,
magic_dns_enabled: instance.magic_dns_enabled,
need_exit_node: instance.need_exit_node,
};
let payload_json = match serde_json::to_string(&payload) {
Ok(json) => json,
Err(err) => {
ohrs_log_error!("[Rust] serialize tun request failed: {}", err);
continue;
}
};
if broadcast_local_socket_message(&mut clients, "tun_request", &payload_json) {
delivered_tun_requests.insert(instance.instance_id.clone());
last_tun_route_signatures
.insert(instance.instance_id.clone(), route_signature);
}
} else {
delivered_tun_requests.remove(&instance.instance_id);
last_tun_route_signatures.remove(&instance.instance_id);
}
}
if !snapshot_enabled
&& (!delivered_tun_requests.is_empty()
|| (saw_running_instance && !saw_tun_candidate)
|| now >= tun_fast_until)
{
tun_bootstrap_done = true;
}
thread::sleep(SOCKET_TICK_INTERVAL);
}
});
match LOCAL_SOCKET_STATE.lock() {
Ok(mut guard) => {
*guard = Some(LocalSocketState {
stop_flag,
socket_path,
worker,
});
true
}
Err(err) => {
ohrs_log_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) => {
ohrs_log_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
}
+511 -146
View File
@@ -1,114 +1,304 @@
mod native_log;
macro_rules! ohrs_log_error {
($($arg:tt)*) => {{
if $crate::platform::logging::log_manager::app_log_enabled(5) {
$crate::platform::logging::log_manager::record_app_log(
5,
"RustOhrs",
&std::format!($($arg)*),
);
}
}};
}
use easytier::common::config::{ConfigFileControl, ConfigLoader, TomlConfigLoader};
macro_rules! ohrs_log_info {
($($arg:tt)*) => {{
if $crate::platform::logging::log_manager::app_log_enabled(4) {
$crate::platform::logging::log_manager::record_app_log(
4,
"RustOhrs",
&std::format!($($arg)*),
);
}
}};
}
macro_rules! ohrs_log_debug {
($($arg:tt)*) => {{
if $crate::platform::logging::log_manager::app_log_enabled(3) {
$crate::platform::logging::log_manager::record_app_log(
3,
"RustOhrs",
&std::format!($($arg)*),
);
}
}};
}
mod config;
mod exports;
mod kernel_bridge;
mod platform;
mod runtime;
use config::repository::{cache_runtime_config_snapshot, start_kernel_with_config_id};
use config::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, SnapshotImportResult};
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::{
set_snapshot_broadcast_enabled, 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 runtime::state::runtime_state::RuntimeAggregateState;
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);
false
#[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(())
}
}
#[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)
}
}
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())
}
#[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)
}
}
}
#[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
}
}
}
#[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;
}
},
Err(e) => {
hilog_error!("[Rust] parse config failed {}", e);
fn stop_web_client(config_id: &str) -> bool {
let managed = match WEB_CLIENTS.lock() {
Ok(mut guard) => guard.remove(config_id),
Err(err) => {
ohrs_log_error!("[Rust] stop_web_client lock failed {}", err);
return false;
}
};
if INSTANCE_MANAGER.list_network_instance_ids().len() > 0 {
hilog_error!("[Rust] there is a running instance!");
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| {
ohrs_log_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()
{
ohrs_log_error!("[Rust] there is a running instance!");
return false;
}
let Some(config_server_url) = config.public_server_url.clone() else {
ohrs_log_error!("[Rust] public_server_url missing for config server mode");
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) => {
ohrs_log_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) => {
ohrs_log_error!("[Rust] store config server client failed {}", err);
false
}
}
}
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))
}
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())
}
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()
}
pub(crate) fn run_network_instance_from_json(cfg_json: &str) -> bool {
let config = match serde_json::from_str::<NetworkConfig>(cfg_json) {
Ok(cfg) => cfg,
Err(e) => {
ohrs_log_error!("[Rust] parse config failed {}", e);
return false;
}
};
if is_config_server_config(&config) {
let Some(config_id) = config.instance_id.as_deref() else {
ohrs_log_error!("[Rust] config server config missing instance id");
return false;
};
let started = run_config_server_instance(config_id, &config);
if started {
cache_runtime_config_snapshot(config_id.to_string(), config_id.to_string(), config);
}
return started;
}
let cfg = match config.gen_config() {
Ok(toml) => toml,
Err(e) => {
ohrs_log_error!("[Rust] parse config failed {}", e);
return false;
}
};
if !INSTANCE_MANAGER.list_network_instance_ids().is_empty() {
ohrs_log_error!("[Rust] there is a running instance!");
return false;
}
if !ensure_local_socket_server_started() {
return false;
}
@@ -117,69 +307,244 @@ pub fn run_network_instance(cfg_json: String) -> bool {
.list_network_instance_ids()
.contains(&inst_id)
{
ohrs_log_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(),
});
}
match INSTANCE_MANAGER.run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG) {
Ok(_) => {
cache_runtime_config_snapshot(inst_id.to_string(), inst_id.to_string(), config);
true
}
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);
Err(err) => {
ohrs_log_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) => {
ohrs_log_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 reset_config_store() -> bool {
exports::config_api::reset_config_store()
}
#[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 set_config_favorite(config_id: String, favorite: bool) -> bool {
exports::config_api::set_config_favorite(config_id, favorite)
}
#[napi]
pub fn import_toml(toml_text: String, display_name: Option<String>) -> Option<String> {
exports::config_api::import_toml(toml_text, display_name)
}
#[napi]
pub fn export_toml(config_id: String) -> Option<String> {
exports::config_api::export_toml(config_id)
}
#[napi]
pub fn export_config_store_snapshot(target_path: String) -> bool {
exports::config_api::export_config_store_snapshot(target_path)
}
#[napi]
pub fn import_config_store_snapshot(source_path: String) -> bool {
exports::config_api::import_config_store_snapshot(source_path)
}
#[napi]
pub fn import_config_store_snapshot_with_result(source_path: String) -> SnapshotImportResult {
exports::config_api::import_config_store_snapshot_with_result(source_path)
}
#[napi]
pub fn start_kernel(config_id: String) -> bool {
exports::runtime_api::start_kernel(config_id, start_kernel_with_config_id)
}
#[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()
}
#[napi]
pub fn set_kernel_snapshot_enabled(enabled: bool) {
set_snapshot_broadcast_enabled(enabled);
}
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,393 @@
use napi_derive_ohos::napi;
use once_cell::sync::Lazy;
use std::collections::VecDeque;
use std::fs::{self, Metadata, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
const LOG_DIR_NAME: &str = "easytier-logs";
const LOG_FILE_PREFIX: &str = "easytier-";
const LOG_FILE_SUFFIX: &str = ".log";
const MAX_LOG_FILES: usize = 10;
const MAX_MEMORY_LINES: usize = 500;
#[derive(Debug, Clone)]
#[napi(object)]
pub struct LogFileInfo {
pub file_name: String,
pub display_name: String,
pub size_bytes: i64,
pub modified_ms: i64,
pub active: bool,
}
#[derive(Clone)]
struct LogOptions {
core_log: bool,
debug_log: bool,
}
impl Default for LogOptions {
fn default() -> Self {
Self {
core_log: false,
debug_log: false,
}
}
}
#[derive(Default)]
struct LogManagerState {
log_dir: Option<PathBuf>,
active_file: Option<PathBuf>,
lines: VecDeque<String>,
options: LogOptions,
}
static LOG_MANAGER: Lazy<Mutex<LogManagerState>> =
Lazy::new(|| Mutex::new(LogManagerState::default()));
static CORE_LOG_ENABLED: AtomicBool = AtomicBool::new(false);
static DEBUG_LOG_ENABLED: AtomicBool = AtomicBool::new(false);
fn now_millis() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or(0)
}
fn sanitize_name(raw: &str) -> String {
let value = raw
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
ch
} else {
'-'
}
})
.collect::<String>();
if value.is_empty() {
"process".to_string()
} else {
value
}
}
fn log_dir(root_dir: &str) -> PathBuf {
Path::new(root_dir).join(LOG_DIR_NAME)
}
fn is_log_file(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.map(|name| name.starts_with(LOG_FILE_PREFIX) && name.ends_with(LOG_FILE_SUFFIX))
.unwrap_or(false)
}
fn sorted_log_files(dir: &Path) -> Vec<PathBuf> {
let mut files = fs::read_dir(dir)
.ok()
.into_iter()
.flat_map(|entries| entries.filter_map(|entry| entry.ok()))
.map(|entry| entry.path())
.filter(|path| is_log_file(path))
.collect::<Vec<_>>();
files.sort_by(|left, right| {
left.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default()
.cmp(
right
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default(),
)
});
files
}
fn current_log_state() -> Option<(PathBuf, Option<PathBuf>)> {
LOG_MANAGER.lock().ok().and_then(|guard| {
guard
.log_dir
.clone()
.map(|dir| (dir, guard.active_file.clone()))
})
}
fn file_name(path: &Path) -> Option<String> {
path.file_name()
.and_then(|value| value.to_str())
.map(|value| value.to_string())
}
fn latest_process_log_file(dir: &Path, process_name: &str) -> Option<PathBuf> {
let suffix = format!("-{}{}", sanitize_name(process_name), LOG_FILE_SUFFIX);
sorted_log_files(dir).into_iter().rev().find(|path| {
path.file_name()
.and_then(|value| value.to_str())
.map(|value| value.ends_with(&suffix))
.unwrap_or(false)
})
}
fn modified_millis(metadata: &Metadata) -> i64 {
metadata
.modified()
.ok()
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_millis().min(i64::MAX as u128) as i64)
.unwrap_or(0)
}
fn resolve_log_file(dir: &Path, requested_name: &str) -> Option<PathBuf> {
if requested_name.contains('/')
|| requested_name.contains('\\')
|| requested_name.contains("..")
{
return None;
}
sorted_log_files(dir).into_iter().find(|path| {
path.file_name()
.and_then(|value| value.to_str())
.map(|value| value == requested_name)
.unwrap_or(false)
})
}
fn cleanup_old_logs(dir: &Path) {
let files = sorted_log_files(dir);
let overflow = files.len().saturating_sub(MAX_LOG_FILES);
for path in files.into_iter().take(overflow) {
let _ = fs::remove_file(path);
}
}
fn push_memory_line(state: &mut LogManagerState, line: String) {
state.lines.push_back(line);
while state.lines.len() > MAX_MEMORY_LINES {
state.lines.pop_front();
}
}
fn append_log_file(path: &Path, line: &str) {
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
let _ = writeln!(file, "{}", line);
}
}
fn should_record_debug(level: i32) -> bool {
level <= 3
}
fn format_line(level: i32, target: &str, message: &str) -> String {
format!("{}[{}] {}", level, target, message.replace('\n', "\\n"))
}
pub(crate) fn configure(core_log: bool, debug_log: bool) {
CORE_LOG_ENABLED.store(core_log, Ordering::Relaxed);
DEBUG_LOG_ENABLED.store(debug_log, Ordering::Relaxed);
if let Ok(mut guard) = LOG_MANAGER.lock() {
guard.options.core_log = core_log;
guard.options.debug_log = debug_log;
}
}
pub(crate) fn app_log_enabled(level: i32) -> bool {
!should_record_debug(level) || DEBUG_LOG_ENABLED.load(Ordering::Relaxed)
}
pub(crate) fn core_log_enabled(level: i32) -> bool {
CORE_LOG_ENABLED.load(Ordering::Relaxed) && app_log_enabled(level)
}
pub(crate) fn record_app_log(level: i32, target: &str, message: &str) {
if !app_log_enabled(level) {
return;
}
if let Ok(mut guard) = LOG_MANAGER.lock() {
let line = format_line(level, target, message);
if let Some(path) = guard.active_file.as_ref() {
append_log_file(path, &line);
}
push_memory_line(&mut guard, line);
}
}
pub(crate) fn record_core_log(level: i32, target: &str, message: &str) {
if !core_log_enabled(level) {
return;
}
if let Ok(mut guard) = LOG_MANAGER.lock() {
let line = format_line(level, target, message);
if let Some(path) = guard.active_file.as_ref() {
append_log_file(path, &line);
}
push_memory_line(&mut guard, line);
}
}
#[napi]
pub fn init_log_manager(root_dir: String, process_name: String) -> bool {
let dir = log_dir(&root_dir);
if fs::create_dir_all(&dir).is_err() {
return false;
}
if LOG_MANAGER
.lock()
.map(|guard| guard.active_file.is_some())
.unwrap_or(false)
{
cleanup_old_logs(&dir);
return true;
}
let sanitized_process_name = sanitize_name(&process_name);
let active_file = if sanitized_process_name == "ui" {
dir.join(format!(
"{}{}-{}-{}{}",
LOG_FILE_PREFIX,
now_millis(),
std::process::id(),
sanitized_process_name,
LOG_FILE_SUFFIX
))
} else if let Some(path) = latest_process_log_file(&dir, "ui") {
path
} else {
dir.join(format!(
"{}{}-{}-{}{}",
LOG_FILE_PREFIX,
now_millis(),
std::process::id(),
sanitized_process_name,
LOG_FILE_SUFFIX
))
};
if OpenOptions::new()
.create(true)
.append(true)
.open(&active_file)
.is_err()
{
return false;
}
if let Ok(mut guard) = LOG_MANAGER.lock() {
guard.log_dir = Some(dir.clone());
guard.active_file = Some(active_file);
guard.lines.clear();
}
cleanup_old_logs(&dir);
true
}
#[napi]
pub fn configure_log_manager(core_log: bool, debug_log: bool) {
configure(core_log, debug_log);
}
#[napi]
pub fn write_app_log(level: i32, target: String, message: String) {
record_app_log(level, &target, &message);
}
#[napi]
pub fn drain_log_lines() -> Vec<String> {
LOG_MANAGER
.lock()
.map(|mut guard| guard.lines.drain(..).collect())
.unwrap_or_default()
}
#[napi]
pub fn list_log_files() -> Vec<LogFileInfo> {
let Some((log_dir, active_file)) = current_log_state() else {
return Vec::new();
};
let active_name = active_file.as_ref().and_then(|path| file_name(path));
let mut files = sorted_log_files(&log_dir);
files.reverse();
files
.into_iter()
.filter_map(|path| {
let file_name = file_name(&path)?;
let active = active_name
.as_ref()
.map(|name| name == &file_name)
.unwrap_or(false);
let metadata = fs::metadata(&path).ok();
Some(LogFileInfo {
file_name,
display_name: if active {
"当前启动日志".to_string()
} else {
"历史日志".to_string()
},
size_bytes: metadata
.as_ref()
.map(|value| value.len().min(i64::MAX as u64) as i64)
.unwrap_or(0),
modified_ms: metadata.as_ref().map(modified_millis).unwrap_or_default(),
active,
})
})
.collect()
}
#[napi]
pub fn read_log_file(file_name: String) -> Option<String> {
let (log_dir, _) = current_log_state()?;
let path = resolve_log_file(&log_dir, &file_name)?;
fs::read_to_string(path).ok()
}
#[napi]
pub fn export_log_file(file_name: String, target_path: String) -> bool {
let Some((log_dir, _)) = current_log_state() else {
return false;
};
let Some(path) = resolve_log_file(&log_dir, &file_name) else {
return false;
};
fs::copy(path, target_path).is_ok()
}
#[napi]
pub fn export_log_archive(target_path: String) -> bool {
let log_dir = LOG_MANAGER
.lock()
.ok()
.and_then(|guard| guard.log_dir.clone());
let Some(log_dir) = log_dir else {
return false;
};
let files = sorted_log_files(&log_dir);
let mut output = match OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&target_path)
{
Ok(file) => file,
Err(_) => return false,
};
for path in files {
let name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("unknown.log");
let _ = writeln!(output, "===== {} =====", name);
if let Ok(content) = fs::read_to_string(&path) {
let _ = writeln!(output, "{}", content);
}
}
true
}
@@ -0,0 +1,2 @@
pub(crate) mod log_manager;
pub(crate) mod native_log;
@@ -1,7 +1,5 @@
use super::log_manager;
use napi_derive_ohos::napi;
use ohos_hilog_binding::{
LogOptions, hilog_debug, hilog_error, hilog_info, hilog_warn, set_global_options,
};
use std::collections::HashMap;
use std::panic;
use tracing::{Event, Subscriber};
@@ -10,8 +8,9 @@ use tracing_subscriber::layer::{Context, Layer};
use tracing_subscriber::prelude::*;
static INITIALIZED: std::sync::Once = std::sync::Once::new();
static TRACING_INITIALIZED: std::sync::Once = std::sync::Once::new();
fn panic_hook(info: &panic::PanicHookInfo) {
hilog_error!("RUST PANIC: {}", info);
log_manager::record_core_log(5, "RustPanic", &format!("{}", info));
}
#[napi]
@@ -23,45 +22,40 @@ pub fn init_panic_hook() {
#[napi]
pub fn hilog_global_options(domain: u32, tag: String) {
ohos_hilog_binding::forward_stdio_to_hilog();
set_global_options(LogOptions {
domain,
tag: Box::leak(tag.clone().into_boxed_str()),
})
let _ = domain;
let _ = tag;
}
#[napi]
pub fn init_tracing_subscriber() {
tracing_subscriber::registry()
.with(CallbackLayer {
callback: Box::new(tracing_callback),
})
.init();
TRACING_INITIALIZED.call_once(|| {
let _ = tracing_subscriber::registry()
.with(CallbackLayer {
callback: Box::new(tracing_callback),
})
.try_init();
});
}
fn tracing_callback(event: &Event, fields: HashMap<String, String>) {
let metadata = event.metadata();
#[cfg(target_env = "ohos")]
{
let loc = metadata.target().split("::").last().unwrap();
match *metadata.level() {
Level::TRACE => {
hilog_debug!("[{}] {:?}", loc, fields.values().collect::<Vec<_>>());
}
Level::DEBUG => {
hilog_debug!("[{}] {:?}", loc, fields.values().collect::<Vec<_>>());
}
Level::INFO => {
hilog_info!("[{}] {:?}", loc, fields.values().collect::<Vec<_>>());
}
Level::WARN => {
hilog_warn!("[{}] {:?}", loc, fields.values().collect::<Vec<_>>());
}
Level::ERROR => {
hilog_error!("[{}] {:?}", loc, fields.values().collect::<Vec<_>>());
}
}
let loc = metadata
.target()
.split("::")
.last()
.unwrap_or(metadata.target());
let level = match *metadata.level() {
Level::TRACE => 2,
Level::DEBUG => 3,
Level::INFO => 4,
Level::WARN => 6,
Level::ERROR => 5,
};
if !log_manager::core_log_enabled(level) {
return;
}
let values = fields.values().cloned().collect::<Vec<_>>().join(" ");
log_manager::record_core_log(level, &format!("Rust:{}", loc), &values);
}
struct CallbackLayer {
@@ -70,6 +64,16 @@ struct CallbackLayer {
impl<S: Subscriber> Layer<S> for CallbackLayer {
fn on_event(&self, event: &Event, _ctx: Context<S>) {
let level = match *event.metadata().level() {
Level::TRACE => 2,
Level::DEBUG => 3,
Level::INFO => 4,
Level::WARN => 6,
Level::ERROR => 5,
};
if !log_manager::core_log_enabled(level) {
return;
}
// 使用 fmt::format::FmtSpan 提取字段值
let mut fields = HashMap::new();
let mut visitor = FieldCollector(&mut fields);
@@ -0,0 +1 @@
pub(crate) mod state;
@@ -0,0 +1 @@
pub(crate) mod runtime_state;
@@ -0,0 +1,464 @@
use easytier::proto::{api, common};
use napi_derive_ohos::napi;
use serde::Serialize;
use std::collections::HashSet;
use std::sync::Mutex;
use url::Url;
static ATTACHED_TUN_INSTANCE_IDS: once_cell::sync::Lazy<Mutex<HashSet<String>>> =
once_cell::sync::Lazy::new(|| Mutex::new(HashSet::new()));
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 non_empty_string(value: Option<String>) -> Option<String> {
value.and_then(|raw| {
let trimmed = raw.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
}
fn config_virtual_ipv4_cidr(config: &api::manage::NetworkConfig) -> Option<String> {
non_empty_string(config.virtual_ipv4.clone())
.map(|ipv4| format!("{}/{}", ipv4, config.network_length.unwrap_or(24)))
}
fn config_endpoint_urls(config: &api::manage::NetworkConfig) -> Vec<String> {
let mut urls = Vec::new();
let mut seen = HashSet::new();
if let Some(url) = non_empty_string(config.public_server_url.clone())
&& seen.insert(url.clone())
{
urls.push(url);
}
for raw in &config.peer_urls {
let trimmed = raw.trim();
if trimmed.is_empty() {
continue;
}
let value = trimmed.to_string();
if seen.insert(value.clone()) {
urls.push(value);
}
}
urls
}
fn endpoint_url(url: &str) -> Option<Url> {
Url::parse(url).ok()
}
fn endpoint_scheme(url: &str) -> Option<String> {
endpoint_url(url)
.map(|parsed| parsed.scheme().to_string())
.or_else(|| {
let scheme = url.split("://").next().unwrap_or("").trim();
(!scheme.is_empty()).then_some(scheme.to_string())
})
}
fn endpoint_label(url: &str) -> String {
if let Some(parsed) = endpoint_url(url)
&& let Some(host) = parsed.host_str()
{
return format!("[Config] {}", host);
}
format!("[Config] {}", url)
}
fn endpoint_remote_display(url: &str) -> String {
if let Some(parsed) = endpoint_url(url)
&& let Some(host) = parsed.host_str()
{
return parsed
.port()
.map(|port| format!("{}:{}", host, port))
.unwrap_or_else(|| host.to_string());
}
url.to_string()
}
fn configured_peer_id(index: usize) -> i64 {
9_000_000 + index as i64
}
fn configured_route_views(endpoints: &[String], public_server_url: Option<&str>) -> Vec<RouteView> {
endpoints
.iter()
.enumerate()
.map(|(index, endpoint)| RouteView {
peer_id: configured_peer_id(index),
hostname: Some(endpoint_label(endpoint)),
ipv4: Some(endpoint_remote_display(endpoint)),
ipv4_cidr: None,
ipv6_cidr: None,
proxy_cidrs: Vec::new(),
next_hop_peer_id: None,
cost: Some(0),
path_latency: None,
udp_nat_type: None,
tcp_nat_type: None,
inst_id: None,
version: None,
is_public_server: public_server_url.map(|url| url == endpoint),
})
.collect()
}
fn configured_peer_views(endpoints: &[String]) -> Vec<PeerInfo> {
endpoints
.iter()
.enumerate()
.map(|(index, endpoint)| {
let conn_id = format!("configured-peer-{}", index);
PeerInfo {
peer_id: configured_peer_id(index),
default_conn_id: Some(conn_id.clone()),
directly_connected_conns: vec![conn_id.clone()],
conns: vec![PeerConnInfo {
conn_id,
my_peer_id: 0,
peer_id: configured_peer_id(index),
features: Vec::new(),
tunnel_type: endpoint_scheme(endpoint),
local_addr: None,
remote_addr: Some(endpoint.clone()),
resolved_remote_addr: Some(endpoint_remote_display(endpoint)),
stats: None,
loss_rate: None,
is_client: true,
network_name: None,
is_closed: false,
secure_auth_level: None,
peer_identity_type: None,
}],
}
})
.collect()
}
fn optional_u32_to_i64(value: Option<u32>) -> Option<i64> {
value.map(|v| v as i64)
}
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(),
}
}
pub fn runtime_instance_from_config_snapshot(
config_id: String,
display_name: String,
config: api::manage::NetworkConfig,
running: bool,
) -> RuntimeInstanceState {
let tun_attached = running && is_tun_attached(&config_id);
let tun_required =
running && (config.dev_name.as_deref().unwrap_or("") != "no_tun" || tun_attached);
let endpoint_urls = config_endpoint_urls(&config);
let public_server_url = non_empty_string(config.public_server_url.clone());
let my_node_info = MyNodeInfo {
virtual_ipv4: non_empty_string(config.virtual_ipv4.clone()),
virtual_ipv4_cidr: config_virtual_ipv4_cidr(&config),
hostname: non_empty_string(config.hostname.clone()),
version: None,
peer_id: None,
listeners: config.listener_urls.clone(),
vpn_portal_cfg: None,
udp_nat_type: None,
tcp_nat_type: None,
};
RuntimeInstanceState {
config_id: config_id.clone(),
instance_id: config_id,
display_name,
running,
tun_required,
tun_attached,
magic_dns_enabled: config.enable_magic_dns.unwrap_or(false),
need_exit_node: !config.exit_nodes.is_empty(),
error_message: None,
my_node_info: Some(my_node_info),
events: Vec::new(),
routes: configured_route_views(&endpoint_urls, public_server_url.as_deref()),
peers: configured_peer_views(&endpoint_urls),
}
}
+2 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "easytier-uptime"
version = "0.1.0"
edition = "2021"
edition.workspace = true
[dependencies]
tokio = { version = "1.0", features = ["full"] }
@@ -12,6 +12,7 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1.0", features = ["v4", "serde"] }
guarden = "0.1"
# Axum web framework
axum = { version = "0.8.4", features = ["macros"] }
@@ -1,7 +1,7 @@
use std::ops::{Div, Mul};
use axum::extract::{Path, State};
use axum::Json;
use axum::extract::{Path, State};
use sea_orm::{
ColumnTrait, Condition, EntityTrait, IntoActiveModel, ModelTrait, Order, PaginatorTrait,
QueryFilter, QueryOrder, QuerySelect, Set, TryIntoModel,
@@ -14,7 +14,7 @@ use crate::api::{
models::*,
};
use crate::db::entity::{self, health_records, shared_nodes};
use crate::db::{operations::*, Db};
use crate::db::{Db, operations::*};
use crate::health_checker_manager::HealthCheckerManager;
use axum_extra::extract::Query;
use std::sync::Arc;
@@ -273,7 +273,7 @@ pub struct InstanceFilterParams {
use crate::config::AppConfig;
use axum::http::{HeaderMap, StatusCode};
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
use serde::Serialize;
#[derive(Debug, Serialize, Deserialize)]
@@ -370,19 +370,19 @@ pub async fn admin_get_nodes(
let ids = NodeOperations::filter_node_ids_by_tag(&app_state.db, &tag).await?;
filtered_ids = Some(ids);
}
if let Some(tags) = filters.tags {
if !tags.is_empty() {
let ids_any = NodeOperations::filter_node_ids_by_tags_any(&app_state.db, &tags).await?;
filtered_ids = match filtered_ids {
Some(mut existing) => {
existing.extend(ids_any);
existing.sort();
existing.dedup();
Some(existing)
}
None => Some(ids_any),
};
}
if let Some(tags) = filters.tags
&& !tags.is_empty()
{
let ids_any = NodeOperations::filter_node_ids_by_tags_any(&app_state.db, &tags).await?;
filtered_ids = match filtered_ids {
Some(mut existing) => {
existing.extend(ids_any);
existing.sort();
existing.dedup();
Some(existing)
}
None => Some(ids_any),
};
}
if let Some(ids) = filtered_ids {
if ids.is_empty() {
@@ -1,5 +1,5 @@
use axum::routing::{delete, get, post, put};
use axum::Router;
use axum::routing::{delete, get, post, put};
use tower_http::compression::CompressionLayer;
use tower_http::cors::CorsLayer;
@@ -1,7 +1,7 @@
use crate::db::entity::*;
use crate::db::Db;
use crate::db::entity::*;
use sea_orm::*;
use tokio::time::{sleep, Duration};
use tokio::time::{Duration, sleep};
use tracing::{error, info, warn};
/// 数据清理策略配置
@@ -5,12 +5,12 @@ pub mod operations;
use std::fmt;
use sea_orm::{
prelude::*, sea_query::OnConflict, ColumnTrait as _, DatabaseConnection, DbErr, EntityTrait,
QueryFilter as _, Set, SqlxSqliteConnector, Statement, TransactionTrait as _,
ColumnTrait as _, DatabaseConnection, DbErr, EntityTrait, QueryFilter as _, Set,
SqlxSqliteConnector, Statement, TransactionTrait as _, prelude::*, sea_query::OnConflict,
};
use sea_orm_migration::MigratorTrait as _;
use serde::{Deserialize, Serialize};
use sqlx::{migrate::MigrateDatabase as _, Sqlite, SqlitePool};
use sqlx::{Sqlite, SqlitePool, migrate::MigrateDatabase as _};
use crate::migrator;
@@ -1,8 +1,8 @@
use crate::api::CreateNodeRequest;
use crate::db::entity::*;
use crate::db::Db;
use crate::db::HealthStats;
use crate::db::HealthStatus;
use crate::db::entity::*;
use sea_orm::*;
use std::collections::{HashMap, HashSet};
@@ -7,21 +7,21 @@ use std::{
use anyhow::Context as _;
use dashmap::DashMap;
use easytier::{
common::{
config::{ConfigFileControl, ConfigLoader, NetworkIdentity, PeerConfig, TomlConfigLoader},
scoped_task::ScopedTask,
common::config::{
ConfigFileControl, ConfigLoader, NetworkIdentity, PeerConfig, TomlConfigLoader,
},
defer,
instance_manager::NetworkInstanceManager,
};
use guarden::defer;
use serde::{Deserialize, Serialize};
use sqlx::any;
use tokio_util::task::AbortOnDropHandle;
use tracing::{debug, error, info, instrument, warn};
use crate::db::{
Db, HealthStatus,
entity::shared_nodes,
operations::{HealthOperations, NodeOperations},
Db, HealthStatus,
};
pub struct HealthCheckOneNode {
@@ -240,7 +240,7 @@ pub struct HealthChecker {
db: Db,
instance_mgr: Arc<NetworkInstanceManager>,
inst_id_map: DashMap<i32, uuid::Uuid>,
node_tasks: DashMap<i32, ScopedTask<()>>,
node_tasks: DashMap<i32, AbortOnDropHandle<()>>,
node_records: Arc<DashMap<i32, HealthyMemRecord>>,
node_cfg: Arc<DashMap<i32, TomlConfigLoader>>,
}
@@ -465,7 +465,7 @@ impl HealthChecker {
}
// 启动健康检查任务
let task = ScopedTask::from(tokio::spawn(Self::node_health_check_task(
let task = AbortOnDropHandle::new(tokio::spawn(Self::node_health_check_task(
node_id,
cfg.get_id(),
Arc::clone(&self.instance_mgr),
@@ -1,11 +1,11 @@
use std::{collections::HashSet, sync::Arc, time::Duration};
use anyhow::Context as _;
use tokio::time::{interval, Interval};
use tokio::time::{Interval, interval};
use tracing::{error, info};
use crate::{
db::{entity::shared_nodes, operations::NodeOperations, Db},
db::{Db, entity::shared_nodes, operations::NodeOperations},
health_checker::HealthChecker,
};
+4 -2
View File
@@ -10,7 +10,7 @@ mod migrator;
use api::routes::create_routes;
use clap::Parser;
use config::AppConfig;
use db::{operations::NodeOperations, Db};
use db::{Db, operations::NodeOperations};
use easytier::common::log;
use health_checker::HealthChecker;
use health_checker_manager::HealthCheckerManager;
@@ -49,7 +49,9 @@ async fn main() -> anyhow::Result<()> {
// 如果提供了管理员密码,设置环境变量
if let Some(password) = args.admin_password {
env::set_var("ADMIN_PASSWORD", password);
unsafe {
env::set_var("ADMIN_PASSWORD", password);
}
}
tracing::info!(
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "easytier-gui",
"type": "module",
"version": "2.6.0",
"version": "2.6.4",
"private": true,
"packageManager": "pnpm@9.12.1+sha512.e5a7e52a4183a02d5931057f7a0dbff9d5e9ce3161e33fa68ae392125b79282a8a8a470a51dfc8a0ed86221442eb2fb57019b0990ed24fab519bf0e1bc5ccfc4",
"scripts": {
@@ -59,4 +59,4 @@
"vue-i18n": "^10.0.0",
"vue-tsc": "^2.1.10"
}
}
}
+10 -11
View File
@@ -1,9 +1,9 @@
[package]
name = "easytier-gui"
version = "2.6.0"
version = "2.6.4"
description = "EasyTier GUI"
authors = ["you"]
edition = "2021"
edition.workspace = true
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -11,15 +11,6 @@ edition = "2021"
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.0.0-rc", features = [] }
# enable thunk-rs when compiling for x86_64 or i686 windows
[target.x86_64-pc-windows-msvc.build-dependencies]
thunk-rs = { git = "https://github.com/easytier/thunk.git", default-features = false, features = ["win7"] }
[target.i686-pc-windows-msvc.build-dependencies]
thunk-rs = { git = "https://github.com/easytier/thunk.git", default-features = false, features = ["win7"] }
[dependencies]
# wry 0.47 may crash on android, see https://github.com/EasyTier/EasyTier/issues/527
@@ -66,6 +57,14 @@ libc = "0.2"
[target.'cfg(target_os = "macos")'.dependencies]
security-framework-sys = "2.9.0"
[build-dependencies]
tauri-build = { version = "2.0.0-rc", features = [] }
thunk-rs = { git = "https://github.com/easytier/thunk.git", default-features = false, features = [
"win7",
] }
[features]
# This feature is used for production builds or when a dev server is not specified, DO NOT REMOVE!!
custom-protocol = ["tauri/custom-protocol"]

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