Compare commits

...
107 Commits
Author SHA1 Message Date
fanyang 09cd15725e perf(udp): add GSO batch send via quinn-udp for UDP tunnel
Replace per-packet socket.send_to() with batch drain + GSO sendmsg
in forward_from_ring_to_udp. Uses quinn-udp's UdpSocketState to
leverage UDP_SEGMENT (GSO) when the kernel supports it.

Architecture:
  1. Await first packet from RingStream
  2. Non-blocking drain up to 8 more packets (noop_waker poll_next)
  3. If all same size + GSO supported: concatenate + single sendmsg
     with UDP_SEGMENT → kernel splits into N UDP datagrams
  4. Otherwise: fallback to per-packet send

Feature gated behind 'udp-gso' (enabled in default features).
Falls back to per-packet send on older kernels (no GSO support).

Benchmark (localhost, no hotpath):
  Before GSO: 1,066K pps
  After GSO:  1,070K pps (noise — localhost has no NIC driver overhead)

GSO will have larger impact on real WAN interfaces where syscall
overhead dominates. Implementation is correct and ready for that.

All 208 peers tests pass. 6 netns tests fail (require root, unchanged).
2026-06-29 03:24:55 +08:00
fanyang 90c45d2964 perf(mpsc): batch writev flush (threshold=8) for TCP — +7% pps
Add configurable batch flush threshold to SpinSink. When threshold > 1,
MpscTunnelSender::send accumulates packets in FramedWriter's BufList
without flushing. After N packets, poll_flush triggers a single writev()
syscall instead of N individual write() syscalls.

Implementation:
- SpinSink: pending_count + batch_threshold atomics
- MpscTunnelSender::send: flush every N packets via writev
- Default threshold=1 (per-packet flush, safe for handshake/control)
- Settable via set_batch_threshold() through PeerConn → Peer → PeerManager
- Bench: HOTPATH_BATCH env var, set after convergence

Batch threshold must be 1 during handshake (control packets are
request-response, can't be delayed). Bench sets threshold=8 only after
routes converge.

Benchmark (no hotpath, 3 runs avg):
  TCP batch=1:  985K pps
  TCP batch=8:  1,053K pps (+7%)
  Ring:         unchanged (flush is no-op for RingSink)
  UDP:          unchanged (flush is no-op for RingSink)

MpscTunnelSender::send avg: 343ns → 213ns (-38%, with hotpath) —
writev writes 8 Bytes in one syscall vs 8 write() calls.

All 210 peers tests pass. 6 netns tests fail (require root, unchanged).
2026-06-29 02:58:08 +08:00
fanyang 0e665eafc6 perf(mpsc): batch flush for ring/UDP, per-packet flush for TCP
RingSink's poll_flush is a no-op (data already in ring buffer after
start_send). Skip explicit poll_flush for ring/UDP tunnels to let
poll_ready handle batching at max_buffer_count.

FramedWriter (TCP) must flush per-packet: noop_waker can't wake the
task when socket write returns Pending, so accumulated data in BufList
would deadlock.

Added direct_batch_flush flag to MpscTunnelSender, set based on
tunnel_type at new_direct time:
  ring/udp: batch_flush = true (skip explicit flush)
  tcp:      batch_flush = false (flush per packet)

Benchmark (no hotpath, 3 runs avg):
  Ring: 1,124K → 1,120K pps (noise — flush was no-op anyway)
  TCP:  975K  → 995K pps (+2%, within noise)
  UDP:  1,066K → 1,081K pps (+1.4%, within noise)

Ring/UDP show no change because RingSink flush was already a no-op.
TCP unchanged because it still flushes per-packet.
The real writev batching opportunity for TCP would require async flush
(not noop_waker), which is a separate optimization direction.

All 210 peers tests pass. 6 netns tests fail (require root, unchanged).
2026-06-29 02:25:23 +08:00
fanyang 97526a95a1 Format 2026-06-29 01:39:40 +08:00
fanyang 2794b96028 docs: re-align bench/007 with origin/main baseline (no hotpath)
Measured true baseline from origin/main worktree (+ bench + loopback fix):
  Ring: 293K → 1,124K pps (+284%)
  TCP:  298K → 975K pps (+227%)
  UDP:  630K → 1,066K pps (+69%)

Updated all tables with calibrated numbers. Restructured optimization
table to show real impact ranking (noop_waker as core breakthrough).
2026-06-29 01:17:24 +08:00
fanyang 42b6d326a8 perf(recv): try_recv fast path + inline in start_peer_recv
Add try_recv fast path to recv_packet_from_chan: try non-blocking
recv first, fall back to recv().await only when channel is empty.

Additionally inline the try_recv into start_peer_recv's loop body,
eliminating the async fn wrapper overhead for the common case (channel
has data).

Also add hotpath measure to DefaultCompressor::decompress for receive
path visibility.

Benchmark (TCP, with hotpath):
  Before: ~449K pps
  After:  ~448K pps (noise — receiver is not the bottleneck)

Key finding: receive side is NOT the bottleneck in one-directional
bench. Sender rate (~448K pps with hotpath, ~984K without) limits
throughput. Receive optimization matters for bidirectional scenarios.

210 peers tests pass. 6 netns tests fail (require root, unchanged).
2026-06-29 00:35:50 +08:00
fanyang b37c1539e1 docs: update bench/007 with hotpath observer effect finding
Key finding: hotpath profiling adds 54-57% overhead to benchmarks.
Real production pps (without hotpath):
  Ring: 1,105K pps (12.4 Gbps)
  TCP:  984K pps (11.0 Gbps)

Added gotcha #15 documenting the observer effect, updated benchmark
tables to show both hotpath/non-hotpath numbers, and noted that
timing data should be calibrated by ~2.3x for real-world estimates.
2026-06-28 23:58:04 +08:00
fanyang 8312400913 perf(packet_def): replace split_off with advance (zero-copy) from #2381
Replace BytesMut::split_off with Buf::advance in packet extraction paths:
- convert_type: advance instead of split_off (TCP/UDP/WG hot path)
- payload_bytes, tunnel_payload_bytes: same
- convert_to_dummy_tunnel_packet: same
- virtual_nic TunZCPacketToBytes: same

split_off creates a second BytesMut sharing the same allocation (Arc
ref count churn). advance just moves the start pointer forward —
zero-copy, zero-alloc, no Arc operations.

Benchmark: pps neutral (glibc handles split_off pattern efficiently),
but eliminates Arc churn and is cleaner code.

Combined with #2385 safe initialization for best of both PRs.
All 53 packet/mpsc/forward_packet tests pass.
2026-06-28 23:26:34 +08:00
fanyang 19647f296a perf(packet_def): apply #2385 ZCPacket safe initialization
Cherry-pick packet_def.rs changes from PR #2385:
- new_with_payload: write_bytes + copy_nonoverlapping (no aliasing check)
- new_for_tun: resize instead of unsafe set_len
- new_for_foreign_network: stack-allocated header + single write
- convert_type: resize instead of set_len

Eliminates UB from set_len on uninitialized memory. copy_nonoverlapping
skips aliasing checks, slightly faster for TCP path.

Benchmark (10s, 1400B):
  TCP:  440K -> 464K pps (+5.5%)
  Ring: 508K -> 473K pps (-7%, header zeroing overhead)

Ring regression is acceptable: ring is only for in-process benchmark,
real deployments use TCP/UDP over WAN where the TCP gain matters.

All 37 packet + 16 forward_packet tests pass.
2026-06-28 23:07:33 +08:00
fanyang 94d056cb78 docs: add bench/007 — hotpath CPU optimization journey
14 gotchas, noop_waker technique, ring/UDP/TCP benchmark results.
2026-06-28 22:43:24 +08:00
fanyang 4c7c3d7672 fix: add loopback to connector bind addresses for localhost connections
set_bind_addr_for_peer_connector collected all local interface IPs
as bind addresses but omitted 127.0.0.1. When connecting to localhost,
the connector would bind to a non-loopback IP (e.g. 172.17.0.2 in
Docker) and fail with connect timeout because routing from non-loopback
to loopback doesn't work.

Fix: prepend 127.0.0.1:0 to the bind address list. The connector tries
all bind addresses, so loopback will be attempted first and succeed for
localhost connections.

Benchmark results in Docker (4 threads, 1400B, 10s):
  Ring: 508K pps, MpscTunnelSender::send 138ns
  UDP:  440K pps, MpscTunnelSender::send 294ns
  TCP:  440K pps, MpscTunnelSender::send 376ns

All three tunnel types now converge and benefit from noop_waker sync send.
2026-06-28 22:40:52 +08:00
fanyang d99efba64f perf(mpsc): extend noop_waker sync send to TCP tunnels
TCP tunnel uses FramedWriter (not RingSink), but start_send is still
sync (writes to BufList in memory). poll_flush does actual TCP write
syscall — noop_waker returns Ok for Pending (data stays in BufList,
flushed on next send when BufList >= 64).

Add TCP benchmark support via HOTPATH_TUNNEL=tcp. Note: TCP/UDP
convergence requires netns in bench environment (connector multi-bind
address behavior doesn't work for localhost without namespaces).

All 210 peers tests pass. Ring tunnel benchmark: 234K -> 508K pps (+117%).
2026-06-28 22:24:09 +08:00
fanyang 6d01908593 perf(mpsc): extend noop_waker sync send to UDP tunnels
UDP tunnel uses RingSink internally (same as ring tunnel). Extend
direct mode to include UDP. Fix poll_flush Pending to return Ok.

Add UDP benchmark support via HOTPATH_TUNNEL=udp env variable.

All 208 peers tests pass. Netns tests unchanged (require root).
2026-06-28 22:00:27 +08:00
fanyang 4b654fc56e perf(mpsc): sync send via noop_waker — +90% pps (249K → 474K)
The async fn Future state machine overhead (~1.9us) dominated
MpscTunnelSender::send, while RingSink operations were only ~40ns.

Breakthrough: make send() an async fn that completes synchronously
on the first poll for the direct (ring tunnel) path. Uses
futures::task::noop_waker() to construct a dummy Context, then calls
Sink trait methods (poll_ready, start_send, poll_flush) directly.
RingSink always returns Ready immediately, so the waker is never
invoked and the async fn completes without yielding.

Channel mode (TCP/UDP/WG tunnels) still uses async send_async()
with proper backpressure. Ring tunnels detected via tunnel_info()
type check in PeerConn.

Results (4 threads, 1400B, 15s):
  pps:                   249K → 474K (+90%)
  send_msg_by_ip:        3.53us → 1.67us (-53%)
  send_msg_internal:     2.40us → 502ns  (-79%)
  MpscTunnelSender::send: 1.97us → 144ns  (-93%)

All 207 peers:: tests pass. Netns-requiring tests (three_node,
credential) unchanged (require root).
2026-06-28 21:15:05 +08:00
fanyang 1fdd4b0abe perf(mpsc): replace Mutex with custom SpinSink (AtomicBool spinlock)
tokio::sync::Mutex and std::sync::Mutex both have !Send guards that
cannot cross await points in multi_thread runtime. Replace with a
custom SpinSink using AtomicBool CAS — the SpinGuard contains only a
&SpinSink reference (SpinSink: Sync via unsafe impl), so it is Send.

Benchmark: pps unchanged (~249K), MpscTunnelSender::send avg 1.97us.
The bottleneck is confirmed to be async fn Future state machine
overhead (~1.9us), not the lock mechanism. RingSink operations are
only ~40ns (poll_ready 15ns + start_send 10ns + poll_flush 15ns).

Further breakthrough requires either:
- Sync send API (bypassing async entirely)
- Concrete type instead of dyn ZCPacketSink (to call RingSink::try_send directly)
2026-06-28 20:37:37 +08:00
fanyang ba4fde40ad perf(mpsc): use try_lock + merged poll_fn for direct sink path
Replace 3 await points (lock().await + feed().await + flush().await)
with try_lock() (sync) + single poll_fn (merged poll_ready + start_send
+ poll_flush).

parking_lot::Mutex cannot be used because MutexGuard is !Send (cannot
cross await in multi_thread runtime). tokio::sync::Mutex try_lock()
returns synchronously and MutexGuard is Send.

Benchmark: pps 250K → 251K (+0.4%), MpscTunnelSender::send avg
2.07us → 1.98us (-90ns). Improvement is small because tokio async
machinery overhead (Future state machine + poll) dominates over
RingSink's actual 40ns operation cost.
2026-06-28 20:10:18 +08:00
fanyang cdec67ff53 perf(mpsc): add direct sink path bypassing channel for PeerConn
MpscTunnelSender now supports two modes:
- Channel mode (existing): try_send to tokio mpsc → receiver task → sink
- Direct mode (new): MpscTunnelSender holds Arc<Mutex<sink>> directly,
  bypassing the channel + receiver task entirely

PeerConn uses new_direct to skip the channel intermediary.

Benchmark result: pps unchanged (~245K). The async fn overhead of
Mutex::lock().await + SinkExt::feed().await + SinkExt::flush().await
(~2us) is comparable to channel try_send (~2us). The bottleneck is
the Sink trait's async poll machinery, not the channel itself.

However, this change provides:
- RingSink timing now fully visible (start_send 10ns, poll_ready 13ns,
  poll_flush 17ns = 40ns/pkt total)
- Reduced architectural complexity (no receiver task for PeerConn)
- Foundation for a sync fast path using RingSink::try_send directly
2026-06-28 19:42:42 +08:00
fanyang 7e0cdfc683 hotpath: add measure_all to RingSink Sink impl
Reveals RingSink operation costs:
  poll_ready: 13ns/call (0.57% total)
  start_send: <26ns/call (below top-15 threshold)
  poll_flush: below top-15 threshold

Confirms ring tunnel sink operations are ~33ns/pkt total,
vs 2.04us for MpscTunnelSender::send (which routes through
tokio mpsc channel). The channel intermediary adds ~2us/pkt
of pure overhead.
2026-06-28 19:15:23 +08:00
fanyang 1d80439c7c perf(mpsc): increase channel capacity 32 → 1024 to reduce fallback
channel(32) was frequently full under high pps, causing try_send to
fail and fall back to send().await (semaphore wait). Increasing to 1024
reduces fallback frequency.

Benchmark (4 threads, 1400B, 15s):
  MpscTunnelSender::send avg: 2.23us → 2.01us (-220ns)
  MpscTunnelSender::send P95: 6.39us → 5.74us (-650ns)
  send_msg_internal avg:      2.67us → 2.45us (-220ns)
  pps:                        ~250K  (unchanged, receiver-bound)

pps unchanged because bottleneck moved to receiver (forward_one_round →
sink.feed/flush). The 220ns/pkt saving is pure CPU efficiency gain.
2026-06-28 19:03:55 +08:00
fanyang 392a970db1 bench: add configurable pipeline depth via HOTPATH_PIPELINE env
FuturesUnordered-based pipeline to overlap encrypt with mpsc_send.
Tested depths 1/4/8/16: max +1.6% at depth=4, within noise. Pipeline
has limited value because try_send fast path eliminates await gaps
that would allow overlap. Default remains depth=1 (serial).
2026-06-28 18:55:36 +08:00
fanyang 368d140b5b hotpath: add measure to try_compress_and_encrypt and get_msg_dst_peer_ipv4
Splits the 1.04us gap between send_msg_by_ip and send_msg_internal:

  try_compress_and_encrypt:     386ns (37%)  ← AES-GCM encrypt + zstd compress
  get_msg_dst_peer_ipv4:        161ns (15%)  ← IP→peer_id route lookup
  run_nic_packet_process_pipeline: 121ns (12%) ← ACL check
  other (fill_hdr + counters):  ~440ns (36%)

The 386ns encrypt is the largest optimization opportunity in this gap:
ring tunnel is in-process, so application-layer encryption may be skippable.
2026-06-28 18:41:40 +08:00
fanyang 57cc9922a4 hotpath: add measure to MpscTunnelSender::send and send_msg_by_ip
Adds hotpath::measure to two critical blind spots in the send chain:

1. MpscTunnelSender::send — the tokio mpsc channel send point, which
   accounts for 84% of PeerConn::send_msg wall time (2.33us/pkt).

2. PeerManager::send_msg_by_ip — the top-level packet send entry point,
   revealing a 1.04us gap between send_msg_by_ip and send_msg_internal
   (encryption + routing + ACL + fan-out).

Full send chain timing now visible:
  send_msg_by_ip:       3.83us
    └─ send_msg_internal: 2.79us  (gap: 1.04us = encrypt + route + ACL)
        └─ MpscTunnelSender::send: 2.33us  (84% of internal)
2026-06-28 18:31:25 +08:00
fanyang 4875393327 perf(traffic_metrics): batch counter updates + sync fast path
Two optimizations to reduce per-packet TrafficMetricRecorder overhead:

1. Batch CounterHandle updates (TRAFFIC_BATCH_SIZE=128): accumulate
   bytes/packets in AtomicU64, flush to CounterHandle (and its
   touch()/Instant::now()) only every 128 packets. Reduces touch
   calls from 4/pkt to 0.03/pkt.

2. Sync fast path: record_tx_fast/record_rx_fast handle the common
   case (peer already resolved) without entering async fn or cloning
   TrafficCounters. Falls back to async record_tx/record_rx only for
   first packet to a new/unresolved peer.

Tests use BATCH_SIZE=1 via cfg(test) for exact counter validation.

Benchmark (4 threads, 1400B, 15s, 3 runs):
  Before: 246K pps, send_msg_internal avg 3.13us
  After:  250K pps, send_msg_internal avg 3.05us
  Delta:  +1.6% pps, -80ns/pkt

All 11 traffic_metrics + send_msg_internal tests pass.
2026-06-28 18:20:15 +08:00
fanyang 37f742272b bench: support mimalloc/jemalloc allocator in cpu_hotspot_ring example
Add #[global_allocator] behind feature flags so the bench can test
different allocators. Previously the example used glibc malloc by
default (easytier-core.rs sets jemalloc/mimalloc only for the bin
target, not examples).

Benchmark (4 threads, 1400B, 15s, clone mode):
  glibc:    246K pps, 3.13us/pkt
  jemalloc: 246K pps, 3.21us/pkt
  mimalloc: 242K pps, 3.25us/pkt

All within noise. Single-threaded clone has low malloc contention;
~1500B small allocs are served efficiently by all tcaches.
2026-06-28 18:03:11 +08:00
fanyang d4ef9decd8 Revert "fix: eliminate unsafe code in packet construction and stats counters"
This reverts commit 3464cb801a.
2026-06-28 14:16:47 +08:00
fanyang 3464cb801a fix: eliminate unsafe code in packet construction and stats counters
Replace all UnsafeCell-based counters with safe alternatives:

- New ShardedCounter: per-thread TLS accumulation (thread_local crate)
  with periodic publish to static AtomicU64. Zero atomic RMW on hot path.
- ACL RuleStatsTracker: remove unsafe Arc<RuleStats> raw pointer
  mutation, use two ShardedCounter fields.
- ZCPacket: replace set_len on uninitialized BytesMut with
  write_bytes + copy_nonoverlapping before set_len.
- StatsManager UnsafeCounter/MetricData: remove UnsafeCell and
  unsafe impl Send/Sync, wrap ShardedCounter. last_updated uses
  AtomicU64 epoch millis.
- Throughput: replace UnsafeCell with AtomicU64.
- secure_datagram: fix grace-window test timestamp.
2026-06-28 13:19:52 +08:00
fanyang e18387b06b perf(mpsc): use try_send fast path to skip semaphore overhead
MpscTunnelSender::send now tries try_send first, falling back to
send().await only when the channel is full. try_send bypasses the
tokio batch_semaphore Acquire::poll + add_permits_locked machinery
(~9.4% of CPU in samply profiling), which is pure overhead when the
channel has capacity.

In the ring-tunnel bench (4 threads, 1400B, 15s) the channel(32) fast
path hits >99%, so the fallback rarely triggers.

Benchmark improvement:
  pps:       230K -> 246K (+7.0%)
  send_msg_internal avg: 3.25us -> 3.13us (-120ns/pkt)
  forward_one_round calls: 2.46M -> 706K (-71%, bigger batches)

All mpsc tests pass.
2026-06-28 12:54:37 +08:00
fanyang 31c639f70c perf(peer_manager): merge redundant dashmap lookup in send_msg_internal
Replace has_peer(dst_peer_id) + send_msg_directly() with a single
get_peer_by_id() call, eliminating one redundant dashmap contains_key
query (~50-100ns) per packet on the direct-peer happy path.

send_msg_directly is no longer called from send_msg_internal but remains
available for other callers. All 7 send_msg_internal tests pass.

Benchmark (4 threads, 1400B pkts, 15s):
  Before: 234K pps, send_msg_internal avg 3.26us
  After:  230K pps, send_msg_internal avg 3.25us
  Delta within noise; ~10-50ns/pkt saved as expected for one fewer hash.
2026-06-28 12:45:05 +08:00
fanyang 79035ea972 perf(hotpath): add cpu_hotspot_ring bench and send-chain optimization plan
- Add measure_all to PeerMap and CidrSet impl blocks (hotpath::measure_all)
- Add [profile.hotpath] for samply-compatible builds (strip=false, debug=line-tables-only)
- Add cpu_hotspot_ring example: 2-node ring tunnel with data-plane flooding (~234K pps)
- Add plans/006-send-chain-cpu-optimization.md based on hotpath+samply 423M sample analysis
  Key findings: dashmap redundancy (14.9%), metrics overhead (8.3%), mpsc (14.1%)
  Target: reduce send_msg_internal from 3.26us to ~2us per packet
2026-06-28 12:45:05 +08:00
fanyangandGitHub 7205517160 perf(core): use quanta::Instant for hot-path timing (#2384)
Replace std::time::Instant with quanta::Instant on per-packet, per-RPC,
and per-session paths. TSC-based, ~5ns vs ~25ns per now() call.

Reuses the existing `extern crate self as hotpath` alias so
`use hotpath::instant::Instant;` resolves to the same quanta type with
or without the hotpath feature. Leaves tokio::time::Instant and
smoltcp::time::Instant untouched.
2026-06-28 10:43:55 +08:00
fanyangandGitHub be2034dd06 feat: add optional hotpath profiling support (#2380)
* feat: add hotpath profiling support
* perf(hotpath): make hotpath an optional dependency
2026-06-27 13:12:28 +08:00
KKRainbowandGitHub f0d00d6161 refactor(web): use generated proto network types (#2373)
* refactor(web): use generated proto network types
* fix(core): preserve dumped config flags
* test(web): cover config flag save paths
* fix(ci): use system protoc before frontend codegen
* fix(ci): serialize frontend-lib builds
2026-06-27 13:09:28 +08:00
fanyangandGitHub 034f5066cd fix(faketcp): handle closed tun reader without panic (#2308)
Handle TUN receive errors by marking the fake TCP stack closed and
clearing registered sockets instead of panicking.

Refuse new sockets on closed stacks and let listeners recreate stacks
when the reader task exits.
2026-06-22 10:54:48 +08:00
fanyangandGitHub 9869ddaa4b fix: clarify config parse errors (#2360)
* fix: improve config parse diagnostics
* fix: polish config error context
* test: cover non-ascii config diagnostics
2026-06-21 21:56:49 +08:00
Luna YaoandGitHub 5ea6766238 fix: raise max_headers in ws handshake to 128 (#2366) 2026-06-21 21:54:07 +08:00
Luna YaoandGitHub 5efbc8587f upgrade guarden to 0.2.0 (#2365) 2026-06-18 23:45:28 +08:00
HYecandGitHub 7632cd64da Fix latency-first routing for direct peers (#2358) 2026-06-16 20:58:07 +08:00
韩嘉乐andGitHub 16b666ad25 fix: route_update message is not lag (#2355) 2026-06-16 00:00:48 +08:00
Luna YaoandGitHub 8909e88484 do not panic when fail to parse flags (#2349) 2026-06-14 13:12:20 +08:00
Luna YaoandGitHub 5edc4cb1cd fix: remove quinn-plaintext (#2345)
Remove quinn-plaintext to fix connection errors caused
by different hash values ​​across platforms.

On x64, maintain compatibility with quinn-plaintext.
2026-06-14 01:21:35 +08:00
韩嘉乐andGitHub e7709f1cb5 [OHOS] feat: improve status manager (#2343)
* fix: improve status manager
2026-06-11 17:27:10 +08: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
275 changed files with 46105 additions and 4299 deletions
+13 -6
View File
@@ -33,6 +33,19 @@ runs:
sudo apt-get install -qqy build-essential mold musl-tools
shell: bash
- name: Setup protoc
uses: arduino/setup-protoc@v3
with:
version: '35.1'
# GitHub repo token to use to avoid rate limiter
repo-token: ${{ inputs.token }}
- name: Verify protoc version
run: |
version="$(protoc --version | tr -d '\r')"
test "$version" = "libprotoc 35.1"
shell: bash
- name: Setup Frontend Environment
if: ${{ inputs.pnpm == 'true' }}
uses: ./.github/actions/prepare-pnpm
@@ -82,9 +95,3 @@ runs:
ar x libgcc.a _ctzsi2.o _clz.o _bswapsi2.o
ar rcs libctz.a _ctzsi2.o _clz.o _bswapsi2.o
shell: bash
- name: Setup protoc
uses: arduino/setup-protoc@v3
with:
# GitHub repo token to use to avoid rate limiter
repo-token: ${{ inputs.token }}
+2 -2
View File
@@ -41,8 +41,8 @@ runs:
pnpm -r install
if [ -n "${{ inputs.build-filter }}" ]; then
echo "Building with filter: ${{ inputs.build-filter }}"
pnpm -r --filter "${{ inputs.build-filter }}" build
pnpm -r --workspace-concurrency=1 --filter "${{ inputs.build-filter }}" build
else
echo "No build filter provided, building all packages"
pnpm -r build
pnpm -r --workspace-concurrency=1 build
fi
+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"]
+5 -2
View File
@@ -157,6 +157,9 @@ jobs:
- uses: mlugg/setup-zig@v2
if: ${{ contains(matrix.OS, 'ubuntu') }}
with:
version: 0.16.0
use-cache: true
- uses: taiki-e/install-action@v2
if: ${{ contains(matrix.OS, 'ubuntu') }}
@@ -178,7 +181,7 @@ jobs:
BUILD=zigbuild
fi
if [[ "$TARGET" =~ ^(riscv64|loongarch64|aarch64).*$ || "$TARGET" =~ windows ]]; then
if [[ "$TARGET" =~ ^(riscv64|loongarch64|aarch64).*$ || "$TARGET" =~ (freebsd|windows) ]]; then
FEATURES="mimalloc"
else
FEATURES="jemalloc"
@@ -227,7 +230,7 @@ jobs:
*) UPX_ARCH="amd64" ;;
esac
UPX_VERSION=5.1.1
UPX_VERSION=4.2.4
UPX_PKG="upx-${UPX_VERSION}-${UPX_ARCH}_linux"
curl -L "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/${UPX_PKG}.tar.xz" -s | tar xJvf -
cp "${UPX_PKG}/upx" .
+1 -1
View File
@@ -11,7 +11,7 @@ on:
image_tag:
description: 'Tag for this image build'
type: string
default: 'v2.6.0'
default: 'v2.6.4'
required: true
mark_latest:
description: 'Mark this image as latest'
+2 -2
View File
@@ -18,7 +18,7 @@ on:
version:
description: 'Version for this release'
type: string
default: 'v2.6.0'
default: 'v2.6.4'
required: true
make_latest:
description: 'Mark this release as latest'
@@ -92,4 +92,4 @@ jobs:
files: |
./zipped_assets/*
token: ${{ secrets.GITHUB_TOKEN }}
tag_name: ${{ inputs.version }}
tag_name: ${{ inputs.version }}
+14 -7
View File
@@ -58,23 +58,26 @@ jobs:
- uses: taiki-e/install-action@cargo-hack
- name: Check Cargo.lock is up to date
run: |
if ! cargo metadata --format-version 1 --locked --no-deps > /dev/null; then
echo "::error::Cargo.lock is out of date. Run cargo generate-lockfile or cargo build locally, then commit Cargo.lock."
exit 1
fi
- name: Check formatting
if: ${{ !cancelled() }}
run: cargo fmt --all -- --check
- name: Check Clippy
if: ${{ !cancelled() }}
run: cargo clippy --all-targets --features full --all -- -D warnings
- name: Check features
if: ${{ !cancelled() }}
run: cargo hack check --package easytier --each-feature --exclude-features macos-ne --verbose
- name: Check Cargo.lock is up to date
if: ${{ !cancelled() }}
run: |
if ! cargo metadata --format-version 1 --locked > /dev/null; then
echo "::error::Cargo.lock is out of date. Run cargo generate-lockfile or cargo build locally, then commit Cargo.lock."
exit 1
fi
pre-test:
name: Build test
runs-on: ubuntu-latest
@@ -125,6 +128,10 @@ jobs:
- name: Setup tools for test
run: sudo apt install bridge-utils
- name: Setup upnpd for test
run: |
sudo apt-get update
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y miniupnpd miniupnpd-iptables iptables
- name: Setup system for test
run: |
+4
View File
@@ -34,6 +34,7 @@ easytier-panic.log
# web
node_modules
easytier-web/frontend-lib/src/generated/
.vite
@@ -43,3 +44,6 @@ easytier-gui/src-tauri/*.sys
.direnv
.flake-profile
# contrib
go.sum
Generated
+901 -246
View File
File diff suppressed because it is too large Load Diff
+6 -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",
@@ -28,3 +27,9 @@ lto = true
codegen-units = 1
opt-level = 3
strip = true
# For hotpath CPU profiling: samply needs debug symbols and unstripped binaries.
[profile.hotpath]
inherits = "release"
strip = false
debug = "line-tables-only"
+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~ |
```
您可以测试节点之间的连通性:
@@ -0,0 +1,426 @@
# hotpath CPU 热点分析与发包链路优化
## 概述
本文档记录了使用 hotpath + samply 对 easytier-core 发包链路进行 CPU 热点分析的全过程,包括工具链搭建、热点定位、优化实施、踩坑记录和最终 benchmark 结果。
## 最终 benchmark 数据
### 真实性能对比(不带 hotpath3 runs average
origin/main baseline 使用 `git worktree` 从 origin/main 构建,仅添加 bench example +
loopback bind fixTCP/UDP convergence 需要)。无任何优化代码。
| Tunnel | origin/main baseline | 优化后 | **提升** | 带宽(优化后) |
| -------- | -------------------- | -------------- | --------- | -------------- |
| **Ring** | 293K pps / 3.3 Gbps | **1,124K pps** | **+284%** | 12.6 Gbps |
| **TCP** | 298K pps / 3.3 Gbps | **975K pps** | **+227%** | 10.9 Gbps |
| **UDP** | 630K pps / 7.1 Gbps | **1,066K pps** | **+69%** | 11.9 Gbps |
UDP baseline 本身较高(630K vs 293K/298K),因为 UDP tunnel 的 forward_from_ring_to_udp
独立 task 提供了天然的 pipeline overlap,部分隐藏了 channel 开销。
### 带 hotpath profilingtiming 可见,但有 observer effect
| Tunnel | 原始 pps | 优化后 pps | 提升 | MpscTunnelSender::send |
| ------ | -------- | ---------- | ----- | ---------------------- |
| Ring | 234K | 478K | +104% | 138ns (原 2.23µs) |
| UDP | N/A | 440K | — | 294ns |
| TCP | N/A | 453K | — | 378ns |
### hotpath observer effect
**hotpath 测量基础设施引入了 ~54-57% 的性能开销:**
| Tunnel | 不带 hotpath | 带 hotpath | hotpath 开销 |
| ------ | ------------ | ---------- | ------------ |
| Ring | 1,124K pps | 478K pps | **-57%** |
| TCP | 975K pps | 453K pps | **-54%** |
**含义:**
- timing 数据里的 `send_msg_by_ip: 2.15µs` 是膨胀值,真实成本 ~0.9µs
- 所有 timing 数据需要按 ~2.3x 校准才能反映真实开销
- hotpath 适用于相对比较(优化前 vs 后),不适用于绝对性能评估
- 生产环境部署不应用 hotpath feature 编译
测试条件:4 threads, 1400B packets, 10s, 宿主机直跑。
---
## 工具链搭建
### hotpath + samply 安装
```bash
# hotpath TUI (console)
cargo install hotpath --version 0.18.0 --bin hotpath --features tui
# hotpath-samply (samply wrapperautospawn 依赖)
cargo install hotpath --version 0.18.0 --bin hotpath-samply
# samply 本体(hotpath-samply 内部 spawn samply record
cargo install samply
```
### 内核参数
samply 需要 perf_event 开销,需要调整内核参数:
```bash
echo '1' | sudo tee /proc/sys/kernel/perf_event_paranoid
echo '65536' | sudo tee /proc/sys/kernel/perf_event_mlock_kb
```
- `perf_event_paranoid` 默认 2(不允许非 root 采样),需降到 1。
- `perf_event_mlock_kb` 默认 516 KB32 核机器上 samply 的 mmap buffer 总量超限,需增大到 65536。
### hotpath profile 编译
```toml
# Cargo.toml
[profile.hotpath]
inherits = "release"
strip = false
debug = "line-tables-only"
```
samply 需要 debug symbols 且不能 strip。release profile 默认 `strip = true`,必须用单独的 profile。
### Docker 隔离环境(可选,TCP/UDP bench
修复 loopback bind 地址后(见坑 11),TCP/UDP bench 可以直接在宿主机上跑,不需要 Docker:
```bash
# Ring(进程内,无需隔离)
HOTPATH_TUNNEL=ring ./target/hotpath/examples/cpu_hotspot_ring
# TCP/UDP(修复后也支持宿主机直跑)
HOTPATH_TUNNEL=tcp ./target/hotpath/examples/cpu_hotspot_ring
```
如果仍有 convergence 问题(多网卡环境),用 Docker 提供独立 netns
```bash
docker run --rm \
-v "$(pwd)/target/hotpath/examples/cpu_hotspot_ring:/bench:ro" \
-e HOTPATH_TUNNEL=tcp \
-e HOTPATH_BENCH_SECS=10 \
fedora:latest \
/bench
```
Docker 镜像需要匹配宿主机的 glibc 版本。Fedora 宿主用 `fedora:latest`
---
## 踩坑记录
### 坑 1samply 报 "failed to spawn samply: No such file or directory"
**现象**hotpath CPU report 显示 `failed to spawn samply: No such file or directory (os error 2)`
**原因**hotpath-samply 只是 wrapper,它内部 spawn `samply record --pid <pid>` 来采集 CPU 样本。samply 本体没装。
**解决**
```bash
cargo install samply
```
如果 autospawn 找不到 hotpath-samply 本身,用环境变量指定完整路径:
```bash
export HOTPATH_SAMPLY_WRAPPER_BIN=~/.cargo/bin/hotpath-samply
```
### 坑 2samply 报 "Failed to start profiling: mmap failed"
**现象**samply 启动后立即报 mmap 失败。
**原因**`perf_event_mlock_kb` 默认只有 516 KB。32 核机器上 samply 为每个 CPU core 创建 mmap buffer,总 mmap 量超过限制。
**解决**
```bash
echo '65536' | sudo tee /proc/sys/kernel/perf_event_mlock_kb
```
### 坑 3samply 报 "samply exited with status exit status: 1"
**现象**samply 被 spawn 了但 exit 1。
**原因**:同坑 2——`perf_event_paranoid = 2` 时非 root 用户无法使用 perf_event_open。
**解决**
```bash
echo '1' | sudo tee /proc/sys/kernel/perf_event_paranoid
```
### 坑 4:火焰图全是地址,看不到符号
**现象**samply profile 打开后火焰图全是 `0x31dd24` 之类的地址。
**原因**samply profile 里存储的是地址(不内联符号化)。符号化在查看时通过 symbol server 动态完成。如果直接下载 raw JSON 上传到 profiler.firefox.com,符号 server 无法访问本地二进制文件。
**解决**:必须用 `samply load` 本地打开(它启动 symbol server 自动做符号化):
```bash
samply load /tmp/hotpath/<session>/hp.json.gz
```
不要下载 JSON 再上传到 profiler.firefox.com。
### 坑 5samply 符号化后 `_dl_mcount_wrapper` 占 18.1%
**现象**send_msg_internal inclusive 分析显示 `_dl_mcount_wrapper` 占 18.1% CPU。
**原因**nm 的动态符号表里 `_dl_mcount_wrapper`0x1498d0)到下一个符号(0x1b3e9e)之间有 **425 KB gap**。nm 的 bisect 查找把 gap 内所有地址错误归因到 `_dl_mcount_wrapper`。gap 里实际是 AVX2 优化的 memmove/memcmp/memset 等函数。
**解决**:用 addr2line 精确解析(而非 nm bisect)。实际开销是 memmove 1.67% + memcmp 0.20% + memset 0.19% = 2.1%,不是 18%。**没有 profiling 钩子**。
### 坑 6parking_lot::MutexGuard 不是 Send
**现象**:使用 `parking_lot::Mutex` 替代 `tokio::sync::Mutex` 后,编译报 31 个 "future cannot be sent between threads safely"。
**原因**`parking_lot::MutexGuard` 刻意不实现 `Send`——锁必须在获取它的同一个线程上释放。在 async fn 里 guard 跨 await 点会导致 Future 不是 Sendtokio multi_thread runtime 拒绝 spawn。
**解决**:自定义 `SpinSink`AtomicBool spinlock),`SpinGuard` 只持有 `&SpinSink` 引用(SpinSink: Sync via unsafe impl),是 Send。
### 坑 7std::sync::MutexGuard 也不是 Send(在某些配置下)
**现象**`std::sync::Mutex` 同样报 "future cannot be sent between threads safely"。
**原因**Rust 标准库的 `MutexGuard` 的 Send 实现依赖于内部类型。`Pin<Box<dyn ZCPacketSink>>` 包含 trait object,某些配置下 guard 不是 Send。
**解决**:用自定义 SpinSink 绕过所有标准 Mutex 实现。
### 坑 8direct sink path 没有性能提升
**现象**:去掉 channel 中转(MpscTunnelSender 直接持有 sink),从 3 个 await 点(lock + feed + flush)改为 try_lock + poll_fn 合并。MpscTunnelSender::send 仍然 ~2µs。
**原因**:瓶颈不在 lock 或 channel,而在 **async fn Future 状态机的固有开销**。每次 `.await` 创建一个 Future struct、poll 它、drop 它。即使 poll 立即返回 Ready,整个 async machinery 开销 ~2µs。RingSink 实际操作只有 ~40ns2%)。
**解决**:用 `noop_waker()` 在 async fn 内部同步调用 Sink trait 方法(poll_ready + start_send + poll_flush)。async fn 在第一次 poll 就同步完成返回——绕过所有 async 调度开销。开销从 2µs 降到 ~140ns。
### 坑 9sync send 破坏了 TCP/UDP tunnel
**现象**:把 `send``async fn` 改为 sync `fn` 后,所有 TCP/UDP 相关测试失败(452 个失败)。
**原因**TCP/UDP tunnel 用 channel mode`MpscTunnel::new`)。sync `send` 的 channel path 只做 `try_send`channel 满时返回 `BufferFull`(丢包),而不是 `send().await`(等待背压)。丢包导致 TCP/UDP 连接握手失败。
**解决**:保持 `send` 为 async fn。direct pathring/UDP/TCP)内部用 noop_waker 同步完成(不 yield)。channel path 仍然走 async `send_async().await`。async fn wrapper 对 direct path 只有 ~100ns 开销(Future struct 创建 + 单次 poll),因为不 yield。
### 坑 10poll_flush Pending 返回 Shutdown 导致连接断开
**现象**noop_waker 模式下,TCP tunnel 的 `poll_flush` 可能返回 Pending(TCP 写缓冲区满)。返回 `Err(Shutdown)` 导致 PeerConn 认为连接断开。
**原因**TCP 的 `FramedWriter::poll_flush` 做实际 socket write(系统调用)。socket 缓冲区满时返回 Pending。数据已经在 BufList 里,不需要 panic。
**解决**poll_flush Pending 时返回 `Ok(())`。数据已在 bufferring buffer 或 BufList),后续操作会消费它。Pending 只意味着 "还没 flush 到网络",不是 "错误"。
### 坑 11TCP/UDP bench convergence 失败
**现象**TCP/UDP tunnel 的 bench 中,两个实例无法建立连接(routes did not converge within 15s)。
**原因**`set_bind_addr_for_peer_connector`connector/mod.rs:70-77)收集所有本机 IP 作为 TCP bind 地址,但不包含 `127.0.0.1`。connector 绑定到 `172.17.0.2`Docker eth0)后连接 `127.0.0.1` 路由不通 → 2 秒超时。
**解决**:在 bind 地址列表头部加入 `127.0.0.1:0`。connector 遍历所有 bind 地址,loopback 先被尝试,localhost 连接成功。
### 坑 12ShardedCounter (#2385) 在高频路径引入回退
**现象**cherry-pick PR #2385ShardedCounter 替代 UnsafeCell)后,pps 下降 17%246K → 203K)。
**原因**ShardedCounter 的 TLS 分片设计优化多线程 contention,但每包调用 16 次 `ShardedCounter::add`TLS load + store),单次 ~14ns,总 224ns/包。比原来的 `UnsafeCell`(~2ns/次)高 6 倍。每包 16 次的调用频率让 TLS 开销累积。
**教训**TLS 分片策略适合 **低频高并发** 场景,不适合 **高频单线程** 的发包热路径。
### 坑 13ZCPacket pool 不如 glibc tcache
**现象**:用 `crossbeam_queue::ArrayQueue` 做 BytesMut 对象池,每包从池取/归还。性能没有提升(甚至 -15%)。
**原因**glibc malloc 对 ~1500 bytes 小块分配有 thread-local cachetcache),单次 alloc ~10-15ns。ArrayQueue 的 pop/push 是 CAS 操作(~20-40ns),比 tcache 更慢。pool 还多了 capacity 检查和 clear 操作。
**教训**:手动对象池在现代 glibc tcache 面前没有优势。真正需要 pool 的场景是避免 munmap(大块 >128KB 分配),不是小块。
### 坑 14Pipeline (FuturesUnordered) 效果微小
**现象**:用 FuturesUnordered 让多个 send_msg_by_ip 并发(pipeline_depth=4),pps 只提升 1.6%。
**原因**try_send fast path 让 MpscTunnelSender::send 立即返回(不 await)。多个 send_msg_by_ip 之间没有自然的时间重叠——它们在 CPU 上是串行的。pipeline 需要利用 await 等待时间,但 fast path 消除了 await。
### 坑 15hotpath 测量引入 54% observer effect
**现象**:同一 binary 带 hotpath feature 和不带 hotpath feature 跑 benchpps 差距巨大。
**数据**
| Tunnel | 不带 hotpath | 带 hotpath | hotpath 开销 |
| ------ | ------------ | ---------- | ------------ |
| Ring | 1,124K pps | 478K pps | **-57%** |
| TCP | 975K pps | 453K pps | **-54%** |
**原因**hotpath `#[measure]` / `#[measure_all]` 在每个标注的 async fn 上包装 Future struct,每次 poll 记录开始/结束时间(quanta::Instant ~5ns × 2)、更新统计(atomic 操作)。measure_all 覆盖的 impl 块内所有方法都被插桩。当有 ~30 个 measure 点在发包热路径上时,累计开销超过 50%。
**教训**
- hotpath timing 数据**适用于相对比较**(优化前 vs 后),**不适用于绝对性能评估**
- 生产环境**不应**用 hotpath feature 编译
- 要获取真实 pps,编译不带 `--features hotpath` 的版本
- timing 数据按 ~2.3x 校准可近似真实开销
---
## 优化实施记录
### 真实提升(不带 hotpathorigin/main baseline 对比)
baseline 构建:`git worktree` 从 origin/main,仅添加 bench example + loopback bind fix。
| Tunnel | baseline | 优化后 | 提升 |
| ------ | -------- | -------------- | --------- |
| Ring | 293K pps | **1,124K pps** | **+284%** |
| TCP | 298K pps | **975K pps** | **+227%** |
| UDP | 630K pps | **1,066K pps** | **+69%** |
### 有效优化(按贡献排序)
| 优化 | 带 hotpath pps 变化 | 真实提升来源 | 机制 |
| ------------------------ | ------------------- | ------------------------ | ---------------------------------------------------------- |
| **noop_waker sync send** | **+90%** | **核心突破** | RingSink/FramedWriter 直接 sync poll,绕过 async machinery |
| try_send fast path | +7% | 次要 | 跳过 tokio mpsc semaphore |
| #2385 ZCPacket safe init | +5% (TCP) | TCP 专属 | copy_nonoverlapping 无 aliasing 检查 |
| metrics batch + sync | +1.6% | 小幅 | batch CounterHandle + sync fast path |
| #2381 advance (零拷贝) | ~0% | 代码质量 | Buf::advance 消除 split_off Arc churn |
| channel 32→1024 | ~0% | 减少 fallback | 更大 buffer |
| 接收侧 try_recv | ~0% (单向) | 双向有价值 | 消除 recv().await async overhead |
| loopback bind fix | — | TCP/UDP convergence 必需 | 127.0.0.1 加入 bind 地址列表 |
### 验证无效并回退
| 尝试 | 结果 | 原因 |
| ---------------------------------- | -------- | ----------------------------------- |
| ShardedCounter (#2385) | -17% pps | TLS 分片高频开销 > UnsafeCell |
| ZCPacket pool | -15% pps | glibc tcache 比 ArrayQueue CAS 更快 |
| Allocator 切换 (jemalloc/mimalloc) | ~0% | 小块分配 tcache 都已足够 |
| Pipeline (FuturesUnordered) | +1.6% | try_send 消除了 await 空隙 |
| dashmap 合并 | ~0% | contains_key 本身 ~50ns |
### noop_waker 技术详解
核心原理:async fn `send()` 内部用 `noop_waker()` 构造 dummy Context,直接调 Sink trait 的 `poll_ready` + `start_send` + `poll_flush`。RingSink 在 ring buffer 不满时所有操作立即返回 Ready——noop_waker 永远不会被触发。
```rust
pub async fn send(&self, item: ZCPacket) -> Result<(), TunnelError> {
if let Some(sink) = &self.direct_sink {
if let Some(mut guard) = sink.try_lock() {
let waker = futures::task::noop_waker();
let mut cx = std::task::Context::from_waker(&waker);
match guard.as_mut().poll_ready(&mut cx) {
Poll::Ready(Ok(())) => {
guard.as_mut().start_send(item)?;
match guard.as_mut().poll_flush(&mut cx) {
Poll::Ready(Err(e)) => return Err(e),
_ => return Ok(()), // Ready(Ok) 或 Pending 都返回 Ok
}
}
// ...
}
}
return Err(TunnelError::BufferFull);
}
// Channel mode: async with backpressure
self.send_async(item).await
}
```
**为什么 Pending 返回 Ok**poll_flush Pending 意味着数据已在 bufferring buffer 或 BufList)但还没 flush 到网络。forward task 或下一次 send 会消费它。这是安全的——数据不丢、不乱序。
**适用范围**:所有 Sink 的 `start_send` 是同步内存操作的 tunnel
- Ring tunnel: RingSink → ring buffer(内存)
- UDP tunnel: RingSink → ring buffer → forward_from_ring_to_udp task → socket
- TCP tunnel: FramedWriter → BufList(内存)→ poll_flush 时 write socket
---
## hotpath measure 布点
### 当前覆盖
```
send_msg_by_ip ✅ measure
├─ try_compress_and_encrypt ✅ measure
├─ get_msg_dst_peer_ipv4 ✅ measure
├─ run_nic_packet_process_pipeline ✅ measure
├─ send_msg_internal ✅ measure
│ ├─ PeerMap::send_msg_directly ✅ measure_all
│ ├─ PeerMap::get_peer_by_id ✅ measure_all
│ ├─ PeerMap::get_gateway_peer_id ✅ measure_all
│ ├─ PeerMap::has_peer ✅ measure_all
│ ├─ record_tx_fast ❌ (sync fn, 无 measure)
│ └─ Peer::send_msg ✅ measure
│ └─ PeerConn::send_msg ✅ measure
│ └─ MpscTunnelSender::send ✅ measure
├─ MpscTunnel::forward_one_round ✅ measure
│ ├─ RingSink::poll_ready ✅ measure_all
│ ├─ RingSink::start_send ✅ measure_all
│ └─ RingSink::poll_flush ✅ measure_all
└─ CidrSet::* ✅ measure_all
```
### 布点排除项(避免与已有 PR 冲突)
| 文件 | 排除原因 |
| ---------------------- | -------------------- |
| stats_manager.rs | PR #2385 重写中 |
| traffic_metrics.rs | 依赖 stats_manager |
| peer_manager.rs (部分) | advisor/001-002 改动 |
| peer_conn.rs (部分) | advisor/001-002 改动 |
| tunnel/mpsc.rs (部分) | perf/001 改动 |
| packet_def.rs | perf/001-003 改动 |
| peer_ospf_route.rs | advisor/003-004 改动 |
---
## 运行方式
### Ring tunnel bench
```bash
cargo build --profile hotpath --features hotpath --example cpu_hotspot_ring
HOTPATH_BENCH_SECS=15 ./target/hotpath/examples/cpu_hotspot_ring
```
### TCP/UDP bench(需要 Docker 隔离)
```bash
docker run --rm \
-v "$(pwd)/target/hotpath/examples/cpu_hotspot_ring:/bench:ro" \
-e HOTPATH_TUNNEL=tcp \
-e HOTPATH_BENCH_SECS=10 \
fedora:latest \
/bench
```
### 带 samply CPU profiling
```bash
export PATH=$HOME/.cargo/bin:$PATH
cargo run --profile hotpath --features hotpath,hotpath-cpu --example cpu_hotspot_ring
# 另一终端查看 CPU top
hotpath console
```
### 环境变量
| 变量 | 默认 | 说明 |
| ---------------------------- | ---- | ----------------------- |
| `HOTPATH_BENCH_SECS` | 30 | 打流持续秒数 |
| `HOTPATH_PKT_SIZE` | 1400 | 包大小 |
| `HOTPATH_TUNNEL` | ring | ring / udp / tcp |
| `HOTPATH_PIPELINE` | 1 | pipeline 深度 |
| `HOTPATH_SAMPLY_WRAPPER_BIN` | — | hotpath-samply 完整路径 |
| `HOTPATH_SAMPLY_BIN` | — | samply 本体完整路径 |
@@ -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)
}
+12 -1
View File
@@ -4,14 +4,25 @@ version = "0.1.0"
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,228 @@
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 collect_runtime_state() -> 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,6 @@
mod protocol;
mod routing;
mod socket_server;
pub(crate) use routing::aggregate_requested_tun_routes;
pub use socket_server::{start_local_socket_server, stop_local_socket_server};
@@ -0,0 +1,93 @@
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(())
}
fn shrink_clients_if_sparse(clients: &mut Vec<UnixStream>) {
let sparse_limit = clients.len().saturating_mul(2).max(4);
if clients.capacity() > sparse_limit {
clients.shrink_to_fit();
}
}
pub(crate) fn broadcast_local_socket_message(
clients: &mut Vec<UnixStream>,
message_type: &str,
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);
}
}
shrink_clients_if_sparse(&mut active_clients);
*clients = active_clients;
delivered
}
pub(crate) fn send_local_socket_json_payload_message(
stream: &mut UnixStream,
message_type: &str,
payload_json: &str,
) -> std::io::Result<()> {
let message_type_json = serde_json::to_string(message_type)
.map_err(|err| Error::new(ErrorKind::InvalidData, err.to_string()))?;
let mut raw = Vec::with_capacity(message_type_json.len() + payload_json.len() + 38);
raw.extend_from_slice(b"{\"messageType\":");
raw.extend_from_slice(message_type_json.as_bytes());
raw.extend_from_slice(b",\"payloadJson\":");
raw.extend_from_slice(payload_json.as_bytes());
raw.extend_from_slice(b"}\n");
stream.write_all(&raw)?;
Ok(())
}
pub(crate) fn broadcast_local_socket_json_payload_message(
clients: &mut Vec<UnixStream>,
message_type: &str,
payload_json: &str,
) -> bool {
let mut active_clients = Vec::with_capacity(clients.len());
let mut delivered = false;
for mut client in clients.drain(..) {
if send_local_socket_json_payload_message(&mut client, message_type, payload_json).is_ok() {
delivered = true;
active_clients.push(client);
}
}
shrink_clients_if_sparse(&mut active_clients);
*clients = active_clients;
delivered
}
@@ -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,587 @@
use super::protocol::{
TunRequestPayload, broadcast_local_socket_json_payload_message, broadcast_local_socket_message,
};
use crate::collect_runtime_state_inner;
use crate::config::repository::kernel_socket_path;
use crate::kernel_bridge::routing::aggregate_tun_routes;
use crate::runtime::state::runtime_state::{
PeerConnInfo as RuntimePeerConnInfo, RuntimeAggregateState, peer_conn_to_view,
};
use crate::{ASYNC_RUNTIME, INSTANCE_MANAGER};
use easytier::common::global_ctx::{EventBusSubscriber, GlobalCtxEvent};
use easytier::proto::api::instance::ListPeerRequest;
use easytier::proto::rpc_types::controller::BaseController;
use once_cell::sync::Lazy;
use serde::Serialize;
use std::collections::{HashMap, HashSet};
use std::hash::Hash;
use std::io::ErrorKind;
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::PathBuf;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
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));
const SOCKET_TICK_INTERVAL: Duration = Duration::from_millis(250);
const TRAFFIC_STATS_INTERVAL: Duration = Duration::from_secs(1);
const INSTANCE_POLL_INTERVAL: Duration = Duration::from_secs(1);
const TUN_FAST_CHECK_WINDOW: Duration = Duration::from_secs(8);
const EVENT_RECEIVER_SYNC_INTERVAL: Duration = Duration::from_secs(1);
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct TrafficStatsPayload {
instances: Vec<InstanceTrafficStats>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct InstanceTrafficStats {
config_id: String,
instance_id: String,
rx_bytes: i64,
tx_bytes: i64,
peers: Vec<PeerTrafficStats>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct PeerTrafficStats {
peer_id: i64,
rx_bytes: i64,
tx_bytes: i64,
total_bytes: i64,
latency_us: i64,
loss_rate: f64,
}
struct PendingPeerEvent {
event: &'static str,
instance_id: String,
peer_id: i64,
conn: Option<RuntimePeerConnInfo>,
}
#[derive(Default)]
struct DrainedKernelEvents {
tun_refresh: bool,
topology_lost: bool,
peer_events: Vec<PendingPeerEvent>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct RuntimePeerEventPayload {
event: &'static str,
config_id: String,
instance_id: String,
peer_id: i64,
conn: Option<RuntimePeerConnInfo>,
}
fn shrink_hash_map_if_sparse<K: Eq + Hash, V>(map: &mut HashMap<K, V>) {
let sparse_limit = map.len().saturating_mul(2).max(8);
if map.capacity() > sparse_limit {
map.shrink_to_fit();
}
}
fn shrink_hash_set_if_sparse<T: Eq + Hash>(set: &mut HashSet<T>) {
let sparse_limit = set.len().saturating_mul(2).max(8);
if set.capacity() > sparse_limit {
set.shrink_to_fit();
}
}
fn sync_tun_event_receivers(receivers: &mut HashMap<String, EventBusSubscriber>) {
let mut active_instance_ids = HashSet::new();
for instance in INSTANCE_MANAGER.iter() {
let instance_id = instance.key().to_string();
active_instance_ids.insert(instance_id.clone());
if !receivers.contains_key(&instance_id)
&& let Some(receiver) = instance.value().subscribe_event()
{
receivers.insert(instance_id, receiver);
}
}
receivers.retain(|instance_id, _| active_instance_ids.contains(instance_id));
shrink_hash_map_if_sparse(receivers);
}
fn event_needs_tun_refresh(event: &GlobalCtxEvent) -> bool {
matches!(
event,
GlobalCtxEvent::DhcpIpv4Changed(_, _)
| GlobalCtxEvent::ProxyCidrsUpdated(_, _)
| GlobalCtxEvent::PublicIpv6RoutesUpdated(_, _)
)
}
fn drain_kernel_events(receivers: &mut HashMap<String, EventBusSubscriber>) -> DrainedKernelEvents {
let mut drained = DrainedKernelEvents::default();
let mut closed_receivers = Vec::new();
for (instance_id, receiver) in receivers.iter_mut() {
loop {
match receiver.try_recv() {
Ok(event) => {
drained.tun_refresh = event_needs_tun_refresh(&event) || drained.tun_refresh;
match event {
GlobalCtxEvent::PeerAdded(peer_id) => {
drained.peer_events.push(PendingPeerEvent {
event: "peer_added",
instance_id: instance_id.clone(),
peer_id: peer_id as i64,
conn: None,
});
}
GlobalCtxEvent::PeerRemoved(peer_id) => {
drained.peer_events.push(PendingPeerEvent {
event: "peer_removed",
instance_id: instance_id.clone(),
peer_id: peer_id as i64,
conn: None,
});
}
GlobalCtxEvent::PeerConnAdded(conn_info) => {
let peer_id = conn_info.peer_id as i64;
drained.peer_events.push(PendingPeerEvent {
event: "peer_conn_added",
instance_id: instance_id.clone(),
peer_id,
conn: Some(peer_conn_to_view(conn_info)),
});
}
GlobalCtxEvent::PeerConnRemoved(conn_info) => {
let peer_id = conn_info.peer_id as i64;
drained.peer_events.push(PendingPeerEvent {
event: "peer_conn_removed",
instance_id: instance_id.clone(),
peer_id,
conn: Some(peer_conn_to_view(conn_info)),
});
}
_ => {}
}
}
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => break,
Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => {
drained.topology_lost = true;
continue;
}
Err(tokio::sync::broadcast::error::TryRecvError::Closed) => {
closed_receivers.push(instance_id.clone());
break;
}
}
}
}
for instance_id in closed_receivers {
receivers.remove(&instance_id);
}
drained
}
fn broadcast_runtime_peer_events(
clients: &mut Vec<UnixStream>,
peer_events: Vec<PendingPeerEvent>,
) {
for event in peer_events {
let payload = RuntimePeerEventPayload {
event: event.event,
config_id: event.instance_id.clone(),
instance_id: event.instance_id,
peer_id: event.peer_id,
conn: event.conn,
};
match serde_json::to_string(&payload) {
Ok(json) => {
let _ = broadcast_local_socket_json_payload_message(
clients,
"runtime_peer_event",
&json,
);
}
Err(err) => {
ohrs_log_error!("[Rust] serialize runtime peer event failed: {}", err);
}
}
}
}
fn tun_candidate_ids(snapshot: &RuntimeAggregateState) -> HashSet<String> {
snapshot
.instances
.iter()
.filter(|instance| instance.running && instance.tun_required)
.map(|instance| instance.instance_id.clone())
.collect()
}
fn collect_traffic_stats() -> TrafficStatsPayload {
let services = INSTANCE_MANAGER
.iter()
.filter_map(|instance| {
instance
.value()
.get_api_service()
.map(|api_service| (instance.key().to_string(), api_service))
})
.collect::<Vec<_>>();
let instances = ASYNC_RUNTIME.block_on(async {
let mut instances = Vec::new();
for (instance_id, api_service) in services {
let peers = match api_service
.get_peer_manage_service()
.list_peer(BaseController::default(), ListPeerRequest::default())
.await
{
Ok(response) => response.peer_infos,
Err(err) => {
ohrs_log_debug!(
"[Rust] collect traffic stats list_peer failed instance={}: {}",
instance_id,
err
);
continue;
}
};
let mut instance_rx_bytes = 0i64;
let mut instance_tx_bytes = 0i64;
let mut peer_stats = Vec::with_capacity(peers.len());
for peer in peers {
let mut peer_rx_bytes = 0i64;
let mut peer_tx_bytes = 0i64;
let mut latency_us = i64::MAX;
let mut loss_rate = 0f64;
for conn in peer.conns {
if let Some(stats) = conn.stats {
let rx_bytes = stats.rx_bytes as i64;
let tx_bytes = stats.tx_bytes as i64;
peer_rx_bytes += rx_bytes;
peer_tx_bytes += tx_bytes;
latency_us = latency_us.min(stats.latency_us as i64);
}
loss_rate = loss_rate.max(conn.loss_rate as f64);
}
instance_rx_bytes += peer_rx_bytes;
instance_tx_bytes += peer_tx_bytes;
peer_stats.push(PeerTrafficStats {
peer_id: peer.peer_id as i64,
rx_bytes: peer_rx_bytes,
tx_bytes: peer_tx_bytes,
total_bytes: peer_rx_bytes + peer_tx_bytes,
latency_us: if latency_us == i64::MAX {
-1
} else {
latency_us
},
loss_rate,
});
}
instances.push(InstanceTrafficStats {
config_id: instance_id.clone(),
instance_id,
rx_bytes: instance_rx_bytes,
tx_bytes: instance_tx_bytes,
peers: peer_stats,
});
}
instances
});
TrafficStatsPayload { instances }
}
pub fn start_local_socket_server() -> bool {
let socket_path = match kernel_socket_path() {
Some(path) => path,
None => {
ohrs_log_error!("[Rust] kernel socket path unavailable");
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_topology_json = String::new();
let mut delivered_tun_requests = HashSet::new();
let mut last_tun_route_signatures = HashMap::<String, String>::new();
let mut tun_fast_until = Instant::now() + TUN_FAST_CHECK_WINDOW;
let mut tun_bootstrap_done = false;
let mut last_event_receiver_sync_at: Option<Instant> = None;
let mut last_traffic_stats_at: Option<Instant> = None;
let mut last_instance_poll_at: Option<Instant> = None;
let mut tun_event_receivers = HashMap::<String, EventBusSubscriber>::new();
let mut clients = Vec::<UnixStream>::new();
while !worker_stop_flag.load(Ordering::Relaxed) {
let mut full_topology_dirty = false;
let mut accepted_client = false;
loop {
match listener.accept() {
Ok((stream, _addr)) => {
accepted_client = true;
full_topology_dirty = true;
clients.push(stream);
tun_fast_until = Instant::now() + TUN_FAST_CHECK_WINDOW;
tun_bootstrap_done = false;
}
Err(err) if err.kind() == ErrorKind::WouldBlock => break,
Err(err) => {
ohrs_log_error!("[Rust] accept localsocket failed: {}", err);
break;
}
}
}
if clients.is_empty() {
if !last_topology_json.is_empty() {
last_topology_json.clear();
last_topology_json.shrink_to_fit();
}
delivered_tun_requests.clear();
shrink_hash_set_if_sparse(&mut delivered_tun_requests);
last_tun_route_signatures.clear();
shrink_hash_map_if_sparse(&mut last_tun_route_signatures);
tun_event_receivers.clear();
shrink_hash_map_if_sparse(&mut tun_event_receivers);
clients.shrink_to_fit();
last_event_receiver_sync_at = None;
last_traffic_stats_at = None;
last_instance_poll_at = None;
tun_bootstrap_done = false;
thread::sleep(SOCKET_TICK_INTERVAL);
continue;
}
let now = Instant::now();
let should_sync_event_receivers = accepted_client
|| last_event_receiver_sync_at
.map(|last| now.duration_since(last) >= EVENT_RECEIVER_SYNC_INTERVAL)
.unwrap_or(true);
if should_sync_event_receivers {
sync_tun_event_receivers(&mut tun_event_receivers);
last_event_receiver_sync_at = Some(now);
}
let drained_events = drain_kernel_events(&mut tun_event_receivers);
let tun_refresh = drained_events.tun_refresh;
let topology_lost = drained_events.topology_lost;
let peer_events = drained_events.peer_events;
if topology_lost {
full_topology_dirty = true;
}
if tun_refresh {
tun_bootstrap_done = false;
tun_fast_until = now + TUN_FAST_CHECK_WINDOW;
}
if !peer_events.is_empty() {
broadcast_runtime_peer_events(&mut clients, peer_events);
}
let should_collect_traffic_stats = last_traffic_stats_at
.map(|last| now.duration_since(last) >= TRAFFIC_STATS_INTERVAL)
.unwrap_or(true);
if should_collect_traffic_stats {
last_traffic_stats_at = Some(now);
match serde_json::to_string(&collect_traffic_stats()) {
Ok(json) => {
let _ = broadcast_local_socket_json_payload_message(
&mut clients,
"traffic_stats",
&json,
);
}
Err(err) => {
ohrs_log_error!("[Rust] serialize traffic stats failed: {}", err);
}
}
}
let should_poll_instance = last_instance_poll_at
.map(|last| now.duration_since(last) >= INSTANCE_POLL_INTERVAL)
.unwrap_or(true);
let should_collect_topology = accepted_client
|| full_topology_dirty
|| tun_refresh
|| should_poll_instance
|| (!tun_bootstrap_done && now < tun_fast_until);
if !should_collect_topology {
thread::sleep(SOCKET_TICK_INTERVAL);
continue;
}
let snapshot = collect_runtime_state_inner();
last_instance_poll_at = Some(now);
match serde_json::to_string(&snapshot) {
Ok(json) => {
if accepted_client || full_topology_dirty || json != last_topology_json {
let _ = broadcast_local_socket_json_payload_message(
&mut clients,
"runtime_topology",
&json,
);
last_topology_json = json;
}
}
Err(err) => {
ohrs_log_error!("[Rust] serialize runtime topology failed: {}", err);
}
}
let active_tun_candidate_ids = tun_candidate_ids(&snapshot);
delivered_tun_requests
.retain(|instance_id| active_tun_candidate_ids.contains(instance_id));
last_tun_route_signatures
.retain(|instance_id, _| active_tun_candidate_ids.contains(instance_id));
shrink_hash_set_if_sparse(&mut delivered_tun_requests);
shrink_hash_map_if_sparse(&mut last_tun_route_signatures);
let mut saw_running_instance = false;
let mut saw_tun_candidate = false;
for instance in snapshot.instances.iter() {
if instance.running {
saw_running_instance = true;
}
if !(instance.running && instance.tun_required) {
continue;
}
saw_tun_candidate = true;
let virtual_ipv4 = instance
.my_node_info
.as_ref()
.and_then(|info| info.virtual_ipv4.clone());
let virtual_ipv4_cidr = instance
.my_node_info
.as_ref()
.and_then(|info| info.virtual_ipv4_cidr.clone());
if clients.is_empty() {
continue;
}
if virtual_ipv4.is_none() || virtual_ipv4_cidr.is_none() {
continue;
}
let aggregated_routes = aggregate_tun_routes(instance);
let route_signature = serde_json::to_string(&(
&virtual_ipv4,
&virtual_ipv4_cidr,
&aggregated_routes,
instance.magic_dns_enabled,
instance.need_exit_node,
))
.unwrap_or_else(|_| "[]".to_string());
let should_send = !delivered_tun_requests.contains(&instance.instance_id)
|| last_tun_route_signatures
.get(&instance.instance_id)
.map(|value| value != &route_signature)
.unwrap_or(true);
if !should_send {
continue;
}
let payload = TunRequestPayload {
config_id: instance.config_id.clone(),
instance_id: instance.instance_id.clone(),
display_name: instance.display_name.clone(),
virtual_ipv4,
virtual_ipv4_cidr,
aggregated_routes,
magic_dns_enabled: instance.magic_dns_enabled,
need_exit_node: instance.need_exit_node,
};
let payload_json = match serde_json::to_string(&payload) {
Ok(json) => json,
Err(err) => {
ohrs_log_error!("[Rust] serialize tun request failed: {}", err);
continue;
}
};
if broadcast_local_socket_message(&mut clients, "tun_request", &payload_json) {
delivered_tun_requests.insert(instance.instance_id.clone());
last_tun_route_signatures.insert(instance.instance_id.clone(), route_signature);
}
}
if !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
}
+501 -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::{
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,234 @@ 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")
);
}
}
pub(crate) fn collect_runtime_state_inner() -> RuntimeAggregateState {
exports::runtime_api::collect_runtime_state()
}
#[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),
}
}
pub(crate) 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),
}
}
@@ -12,6 +12,7 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1.0", features = ["v4", "serde"] }
guarden = "0.1"
# Axum web framework
axum = { version = "0.8.4", features = ["macros"] }
@@ -7,15 +7,15 @@ use std::{
use anyhow::Context as _;
use dashmap::DashMap;
use easytier::{
common::{
config::{ConfigFileControl, ConfigLoader, NetworkIdentity, PeerConfig, TomlConfigLoader},
scoped_task::ScopedTask,
common::config::{
ConfigFileControl, ConfigLoader, NetworkIdentity, PeerConfig, TomlConfigLoader,
},
defer,
instance_manager::NetworkInstanceManager,
};
use guarden::defer;
use serde::{Deserialize, Serialize};
use sqlx::any;
use tokio_util::task::AbortOnDropHandle;
use tracing::{debug, error, info, instrument, warn};
use crate::db::{
@@ -240,7 +240,7 @@ pub struct HealthChecker {
db: Db,
instance_mgr: Arc<NetworkInstanceManager>,
inst_id_map: DashMap<i32, uuid::Uuid>,
node_tasks: DashMap<i32, ScopedTask<()>>,
node_tasks: DashMap<i32, AbortOnDropHandle<()>>,
node_records: Arc<DashMap<i32, HealthyMemRecord>>,
node_cfg: Arc<DashMap<i32, TomlConfigLoader>>,
}
@@ -465,7 +465,7 @@ impl HealthChecker {
}
// 启动健康检查任务
let task = ScopedTask::from(tokio::spawn(Self::node_health_check_task(
let task = AbortOnDropHandle::new(tokio::spawn(Self::node_health_check_task(
node_id,
cfg.get_id(),
Arc::clone(&self.instance_mgr),
+4 -4
View File
@@ -1,12 +1,12 @@
{
"name": "easytier-gui",
"type": "module",
"version": "2.6.0",
"version": "2.6.4",
"private": true,
"packageManager": "pnpm@9.12.1+sha512.e5a7e52a4183a02d5931057f7a0dbff9d5e9ce3161e33fa68ae392125b79282a8a8a470a51dfc8a0ed86221442eb2fb57019b0990ed24fab519bf0e1bc5ccfc4",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"dev": "pnpm --dir ../easytier-web/frontend-lib build && vite",
"build": "pnpm --dir ../easytier-web/frontend-lib build && vue-tsc --noEmit && vite build",
"preview": "vite preview",
"tauri": "tauri",
"lint": "eslint . --ignore-pattern src-tauri",
@@ -59,4 +59,4 @@
"vue-i18n": "^10.0.0",
"vue-tsc": "^2.1.10"
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "easytier-gui"
version = "2.6.0"
version = "2.6.4"
description = "EasyTier GUI"
authors = ["you"]
edition.workspace = true
@@ -1,5 +1,6 @@
import java.util.Properties
import java.io.FileInputStream
import groovy.json.JsonSlurper
plugins {
id("com.android.application")
@@ -14,6 +15,35 @@ val tauriProperties = Properties().apply {
}
}
val versionPattern = Regex("""^(\d+)\.(\d+)\.(\d+)$""")
val tauriVersionName = tauriProperties.getProperty("tauri.android.versionName")?.ifBlank { null } ?: run {
val tauriConfFile = file("../../../tauri.conf.json")
check(tauriConfFile.exists()) { "Missing tauri.conf.json at ${tauriConfFile.path}" }
val tauriConf = tauriConfFile.reader(Charsets.UTF_8).use { JsonSlurper().parse(it) as? Map<*, *> }
?: error("Failed to parse ${tauriConfFile.path} as a JSON object")
tauriConf["version"] as? String
?: error("Missing string field \"version\" in ${tauriConfFile.path}")
}
val tauriVersionMatch = versionPattern.matchEntire(tauriVersionName)
?: error("Android version must use x.y.z format, but got \"$tauriVersionName\"")
val tauriVersionCode = if (tauriProperties.getProperty("tauri.android.versionName")?.ifBlank { null } != null) {
val versionCodeProp = tauriProperties.getProperty("tauri.android.versionCode")
if (versionCodeProp != null) {
versionCodeProp.toIntOrNull()
?: error("Property \"tauri.android.versionCode\" must be an integer, but got \"$versionCodeProp\"")
} else {
val (major, minor, patch) = tauriVersionMatch.destructured
major.toInt() * 1_000_000 + minor.toInt() * 1_000 + patch.toInt()
}
} else {
val (major, minor, patch) = tauriVersionMatch.destructured
major.toInt() * 1_000_000 + minor.toInt() * 1_000 + patch.toInt()
}
android {
compileSdk = 34
namespace = "com.kkrainbow.easytier"
@@ -22,8 +52,8 @@ android {
applicationId = "com.kkrainbow.easytier"
minSdk = 24
targetSdk = 34
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
versionCode = tauriVersionCode
versionName = tauriVersionName
}
signingConfigs {
create("release") {
@@ -82,4 +112,4 @@ dependencies {
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
}
apply(from = "tauri.build.gradle.kts")
apply(from = "tauri.build.gradle.kts")
+228 -31
View File
@@ -15,7 +15,9 @@ use easytier::rpc_service::remote_client::{
use easytier::web_client::{self, WebClient};
use easytier::{
common::{
config::{ConfigLoader, FileLoggerConfig, LoggingConfigBuilder, TomlConfigLoader},
config::{
ConfigLoader, ConfigSource, FileLoggerConfig, LoggingConfigBuilder, TomlConfigLoader,
},
log,
},
instance_manager::NetworkInstanceManager,
@@ -118,7 +120,7 @@ async fn run_network_instance(
let client_manager = get_client_manager!()?;
let toml_config = cfg.gen_config().map_err(|e| e.to_string())?;
client_manager
.pre_run_network_instance_hook(&app, &toml_config)
.pre_run_network_instance_hook(&app, &toml_config, manager::PersistedConfigSource::User)
.await?;
client_manager
.handle_run_network_instance(app.clone(), cfg, save)
@@ -207,13 +209,17 @@ async fn update_network_config_state(
.map_err(|e: uuid::Error| e.to_string())?;
let client_manager = get_client_manager!()?;
if !disabled {
let cfg = client_manager
.handle_get_network_config(app.clone(), instance_id)
let (cfg, source) = client_manager
.handle_get_network_config_with_source(app.clone(), instance_id)
.await
.map_err(|e| e.to_string())?;
let toml_config = cfg.gen_config().map_err(|e| e.to_string())?;
client_manager
.pre_run_network_instance_hook(&app, &toml_config)
.pre_run_network_instance_hook(
&app,
&toml_config,
manager::PersistedConfigSource::from_runtime_source(source),
)
.await?;
}
client_manager
@@ -272,7 +278,7 @@ async fn get_config(app: AppHandle, instance_id: String) -> Result<NetworkConfig
#[tauri::command]
async fn load_configs(
app: AppHandle,
configs: Vec<NetworkConfig>,
configs: Vec<manager::StoredGuiConfig>,
enabled_networks: Vec<String>,
) -> Result<(), String> {
get_client_manager!()?
@@ -484,10 +490,18 @@ async fn init_web_client(app: AppHandle, url: Option<String>) -> Result<(), Stri
.ok_or_else(|| "Instance manager is not available".to_string())?;
let hooks = Arc::new(manager::GuiHooks { app: app.clone() });
let machine_id_state_dir = app
.path()
.app_data_dir()
.with_context(|| "Failed to resolve machine id state directory")
.map_err(|e| format!("{:#}", e))?;
let web_client = web_client::run_web_client(
url.as_str(),
None,
easytier::common::MachineIdOptions {
explicit_machine_id: None,
state_dir: Some(machine_id_state_dir),
},
None,
false,
instance_manager,
@@ -612,7 +626,11 @@ mod manager {
) -> Result<(), String> {
let client_manager = get_client_manager!()?;
client_manager
.pre_run_network_instance_hook(&self.app, cfg)
.pre_run_network_instance_hook(
&self.app,
cfg,
PersistedConfigSource::from_runtime_source(cfg.get_network_config_source()),
)
.await
}
@@ -631,14 +649,88 @@ mod manager {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub(super) enum PersistedConfigSource {
User,
#[serde(alias = "webhook")]
Web,
#[serde(other)]
#[default]
Legacy,
}
impl PersistedConfigSource {
pub(super) fn from_runtime_source(source: ConfigSource) -> Self {
match source {
ConfigSource::User => Self::User,
ConfigSource::Web => Self::Web,
}
}
fn merge_persisted(self, incoming: Self) -> Self {
match (self, incoming) {
// Older runtimes report missing source as `user`. Keep the stronger persisted
// ownership until web sync or an explicit user save repairs it.
(Self::Web, Self::User) | (Self::Legacy, Self::User) => self,
(_, next) => next,
}
}
fn to_runtime_source(self) -> ConfigSource {
match self {
Self::User | Self::Legacy => ConfigSource::User,
Self::Web => ConfigSource::Web,
}
}
#[cfg(any(test, target_os = "android"))]
fn is_web_like(self) -> bool {
matches!(self, Self::Web)
}
}
#[derive(Clone)]
pub(super) struct GUIConfig(String, pub(crate) NetworkConfig);
pub(super) struct GUIConfig {
inst_id: String,
pub(crate) config: NetworkConfig,
source: PersistedConfigSource,
}
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub(super) struct StoredGuiConfig {
config: NetworkConfig,
#[serde(default)]
source: PersistedConfigSource,
}
impl GUIConfig {
fn new(inst_id: String, config: NetworkConfig, source: PersistedConfigSource) -> Self {
Self {
inst_id,
config,
source,
}
}
fn into_stored(self) -> StoredGuiConfig {
StoredGuiConfig {
config: self.config,
source: self.source,
}
}
}
impl PersistentConfig<anyhow::Error> for GUIConfig {
fn get_network_inst_id(&self) -> &str {
&self.0
&self.inst_id
}
fn get_network_config(&self) -> Result<NetworkConfig, anyhow::Error> {
Ok(self.1.clone())
Ok(self.config.clone())
}
fn get_network_config_source(&self) -> ConfigSource {
self.source.to_runtime_source()
}
}
@@ -655,13 +747,12 @@ mod manager {
}
fn save_configs(&self, app: &AppHandle) -> anyhow::Result<()> {
let configs: Result<Vec<String>, _> = self
let configs = self
.network_configs
.iter()
.map(|entry| serde_json::to_string(&entry.value().1))
.collect();
let payload = format!("[{}]", configs?.join(","));
app.emit_str("save_configs", payload)?;
.map(|entry| entry.value().clone().into_stored())
.collect::<Vec<_>>();
app.emit("save_configs", configs)?;
Ok(())
}
@@ -680,8 +771,14 @@ mod manager {
app: &AppHandle,
inst_id: Uuid,
cfg: NetworkConfig,
source: PersistedConfigSource,
) -> anyhow::Result<()> {
let config = GUIConfig(inst_id.to_string(), cfg);
let source = self
.network_configs
.get(&inst_id)
.map(|existing| existing.source.merge_persisted(source))
.unwrap_or(source);
let config = GUIConfig::new(inst_id.to_string(), cfg, source);
self.network_configs.insert(inst_id, config);
self.save_configs(app)
}
@@ -693,8 +790,14 @@ mod manager {
app: AppHandle,
network_inst_id: Uuid,
network_config: NetworkConfig,
source: ConfigSource,
) -> Result<(), anyhow::Error> {
self.save_config(&app, network_inst_id, network_config)?;
self.save_config(
&app,
network_inst_id,
network_config,
PersistedConfigSource::from_runtime_source(source),
)?;
self.enabled_networks.insert(network_inst_id);
self.save_enabled_networks(&app)?;
Ok(())
@@ -811,17 +914,35 @@ mod manager {
.network_configs
.iter()
.filter(|v| self.storage.enabled_networks.contains(v.key()))
.filter(|v| !v.1.no_tun())
.filter_map(|c| c.1.instance_id().parse::<uuid::Uuid>().ok())
.filter(|v| !v.config.no_tun())
.filter_map(|c| c.config.instance_id().parse::<uuid::Uuid>().ok())
}
#[cfg(target_os = "android")]
pub fn get_enabled_instances_with_web_like_tun_ids(
&self,
) -> impl Iterator<Item = uuid::Uuid> + '_ {
self.storage
.network_configs
.iter()
.filter(|v| self.storage.enabled_networks.contains(v.key()))
.filter(|v| !v.config.no_tun())
.filter(|v| v.source.is_web_like())
.filter_map(|c| c.config.instance_id().parse::<uuid::Uuid>().ok())
}
#[cfg(target_os = "android")]
pub(super) async fn disable_instances_with_tun(
&self,
app: &AppHandle,
web_only: bool,
) -> Result<(), easytier::rpc_service::remote_client::RemoteClientError<anyhow::Error>>
{
let inst_ids: Vec<uuid::Uuid> = self.get_enabled_instances_with_tun_ids().collect();
let inst_ids: Vec<uuid::Uuid> = if web_only {
self.get_enabled_instances_with_web_like_tun_ids().collect()
} else {
self.get_enabled_instances_with_tun_ids().collect()
};
for inst_id in inst_ids {
self.handle_update_network_state(app.clone(), inst_id, true)
.await?;
@@ -842,6 +963,7 @@ mod manager {
&self,
app: &AppHandle,
cfg: &easytier::common::config::TomlConfigLoader,
source: PersistedConfigSource,
) -> Result<(), String> {
let instance_id = cfg.get_id();
app.emit("pre_run_network_instance", instance_id.to_string())
@@ -849,9 +971,24 @@ mod manager {
#[cfg(target_os = "android")]
if !cfg.get_flags().no_tun {
self.disable_instances_with_tun(app)
.await
.map_err(|e| e.to_string())?;
match source {
PersistedConfigSource::User | PersistedConfigSource::Legacy => {
self.disable_instances_with_tun(app, false)
.await
.map_err(|e| e.to_string())?;
}
PersistedConfigSource::Web => {
self.disable_instances_with_tun(app, true)
.await
.map_err(|e| e.to_string())?;
if self.get_enabled_instances_with_tun_ids().next().is_some() {
return Err(
"Android only supports one active TUN network; user-managed VPN remains active"
.to_string(),
);
}
}
}
}
self.storage
@@ -859,6 +996,7 @@ mod manager {
app,
instance_id,
NetworkConfig::new_from_config(cfg).map_err(|e| e.to_string())?,
source,
)
.map_err(|e| e.to_string())?;
@@ -962,15 +1100,15 @@ mod manager {
pub(super) async fn load_configs(
&self,
app: AppHandle,
configs: Vec<NetworkConfig>,
configs: Vec<StoredGuiConfig>,
enabled_networks: Vec<String>,
) -> anyhow::Result<()> {
self.storage.network_configs.clear();
for cfg in configs {
let instance_id = cfg.instance_id();
for stored in configs {
let instance_id = stored.config.instance_id();
self.storage.network_configs.insert(
instance_id.parse()?,
GUIConfig(instance_id.to_string(), cfg),
GUIConfig::new(instance_id.to_string(), stored.config, stored.source),
);
}
@@ -986,12 +1124,12 @@ mod manager {
.storage
.network_configs
.get(&uuid)
.map(|i| i.value().1.clone());
let Some(config) = config else {
.map(|i| (i.value().config.clone(), i.value().source));
let Some((config, source)) = config else {
continue;
};
let toml_config = config.gen_config()?;
self.pre_run_network_instance_hook(&app, &toml_config)
self.pre_run_network_instance_hook(&app, &toml_config, source)
.await
.map_err(|e| anyhow::anyhow!(e))?;
client
@@ -1001,6 +1139,7 @@ mod manager {
inst_id: None,
config: Some(config),
overwrite: false,
source: source.to_runtime_source().to_rpc(),
},
)
.await?;
@@ -1032,6 +1171,64 @@ mod manager {
&self.storage
}
}
#[cfg(test)]
mod tests {
use super::{PersistedConfigSource, StoredGuiConfig};
use easytier::proto::api::manage::NetworkConfig;
#[test]
fn stored_gui_config_defaults_missing_source_to_legacy() {
let stored: StoredGuiConfig = serde_json::from_value(serde_json::json!({
"config": NetworkConfig::default(),
}))
.unwrap();
assert_eq!(stored.source, PersistedConfigSource::Legacy);
}
#[test]
fn stored_gui_config_deserializes_webhook_source_as_web() {
let stored: StoredGuiConfig = serde_json::from_value(serde_json::json!({
"config": NetworkConfig::default(),
"source": "webhook",
}))
.unwrap();
assert_eq!(stored.source, PersistedConfigSource::Web);
}
#[test]
fn stored_gui_config_defaults_unknown_source_to_legacy() {
let stored: StoredGuiConfig = serde_json::from_value(serde_json::json!({
"config": NetworkConfig::default(),
"source": "unknown",
}))
.unwrap();
assert_eq!(stored.source, PersistedConfigSource::Legacy);
}
#[test]
fn persisted_source_merge_keeps_legacy_and_web_over_ambiguous_user() {
assert_eq!(
PersistedConfigSource::Legacy.merge_persisted(PersistedConfigSource::User),
PersistedConfigSource::Legacy
);
assert_eq!(
PersistedConfigSource::Web.merge_persisted(PersistedConfigSource::User),
PersistedConfigSource::Web
);
assert_eq!(
PersistedConfigSource::Legacy.merge_persisted(PersistedConfigSource::Web),
PersistedConfigSource::Web
);
}
#[test]
fn only_web_configs_are_web_like() {
assert!(!PersistedConfigSource::Legacy.is_web_like());
assert!(!PersistedConfigSource::User.is_web_like());
assert!(PersistedConfigSource::Web.is_web_like());
}
}
}
#[cfg(not(target_os = "android"))]
+2 -2
View File
@@ -17,7 +17,7 @@
"createUpdaterArtifacts": false
},
"productName": "easytier-gui",
"version": "2.6.0",
"version": "2.6.4",
"identifier": "com.kkrainbow.easytier",
"plugins": {
"shell": {
@@ -36,4 +36,4 @@
"csp": null
}
}
}
}
+39 -3
View File
@@ -1,7 +1,7 @@
import { invoke } from '@tauri-apps/api/core'
import { Api, NetworkTypes } from 'easytier-frontend-lib'
import { GetNetworkMetasResponse } from 'node_modules/easytier-frontend-lib/dist/modules/api'
import { type ConfigSource, normalizeConfigSource } from './config_source'
type NetworkConfig = NetworkTypes.NetworkConfig
type ValidateConfigResponse = Api.ValidateConfigResponse
@@ -16,6 +16,39 @@ interface ServiceOptions {
export type ServiceStatus = "Running" | "Stopped" | "NotInstalled"
interface StoredGuiConfig {
config: NetworkConfig
source: ConfigSource
}
function parseStoredConfigs(raw: string | null): StoredGuiConfig[] {
const parsed: unknown = JSON.parse(raw || '[]')
if (!Array.isArray(parsed)) {
return []
}
return parsed.flatMap((entry): StoredGuiConfig[] => {
if (entry && typeof entry === 'object' && 'config' in entry) {
const { config, source } = entry as {
config?: NetworkConfig
source?: unknown
}
if (!config) {
return []
}
return [{
config: NetworkTypes.normalizeNetworkConfig(config),
source: normalizeConfigSource(source),
}]
}
return [{
config: NetworkTypes.normalizeNetworkConfig(entry as NetworkConfig),
source: 'legacy',
}]
})
}
export async function parseNetworkConfig(cfg: NetworkConfig) {
return invoke<string>('parse_network_config', { cfg: NetworkTypes.toBackendNetworkConfig(cfg) })
}
@@ -71,9 +104,12 @@ export async function getConfig(instanceId: string) {
}
export async function sendConfigs(enabledNetworks: string[]) {
const networkList: NetworkConfig[] = JSON.parse(localStorage.getItem('networkList') || '[]');
const networkList = parseStoredConfigs(localStorage.getItem('networkList'))
return await invoke('load_configs', {
configs: networkList.map((config) => NetworkTypes.toBackendNetworkConfig(NetworkTypes.normalizeNetworkConfig(config))),
configs: networkList.map(({ config, source }) => ({
config: NetworkTypes.toBackendNetworkConfig(config),
source,
})),
enabledNetworks
})
}
@@ -0,0 +1,13 @@
export type ConfigSource = 'user' | 'web' | 'legacy'
export function normalizeConfigSource(source: unknown): ConfigSource {
if (source === 'user' || source === 'web' || source === 'legacy') {
return source
}
if (source === 'webhook') {
return 'web'
}
return 'legacy'
}
+14 -2
View File
@@ -2,6 +2,12 @@ import { Event, listen } from "@tauri-apps/api/event";
import { type } from "@tauri-apps/plugin-os";
import { NetworkTypes } from "easytier-frontend-lib"
import { Utils } from "easytier-frontend-lib";
import { normalizeConfigSource } from './config_source'
interface StoredGuiConfig {
config: NetworkTypes.NetworkConfig
source?: unknown
}
const EVENTS = Object.freeze({
SAVE_CONFIGS: 'save_configs',
@@ -13,9 +19,15 @@ const EVENTS = Object.freeze({
EVENT_LAGGED: 'event_lagged',
});
function onSaveConfigs(event: Event<NetworkTypes.NetworkConfig[]>) {
function onSaveConfigs(event: Event<StoredGuiConfig[]>) {
console.log(`Received event '${EVENTS.SAVE_CONFIGS}': ${event.payload}`);
localStorage.setItem('networkList', JSON.stringify(event.payload.map((config) => NetworkTypes.normalizeNetworkConfig(config))));
localStorage.setItem(
'networkList',
JSON.stringify(event.payload.map(({ config, source }) => ({
config: NetworkTypes.normalizeNetworkConfig(config),
source: normalizeConfigSource(source),
}))),
);
}
function normalizeInstanceIdPayload(payload: unknown): string {
+1
View File
@@ -18,6 +18,7 @@ export interface ServiceMode extends WebClientConfig {
rpc_portal: string
file_log_level: 'off' | 'warn' | 'info' | 'debug' | 'trace'
file_log_dir: string
installed_core_version?: string
}
export interface RemoteMode {
+20 -3
View File
@@ -16,7 +16,7 @@ import { useToast, useConfirm } from 'primevue'
import { loadMode, saveMode, WebClientConfig, type Mode } from '~/composables/mode'
import { saveLastNetworkInstanceId, loadLastNetworkInstanceId } from '~/composables/config'
import ModeSwitcher from '~/components/ModeSwitcher.vue'
import { getServiceStatus } from '~/composables/backend'
import { getEasytierVersion, getServiceStatus } from '~/composables/backend'
const { t, locale } = useI18n()
const confirm = useConfirm()
@@ -85,6 +85,20 @@ async function onUninstallService() {
});
}
function stripModeMetadata(mode: Mode) {
if (mode.mode !== 'service') {
return mode
}
const serviceConfig = { ...mode }
delete serviceConfig.installed_core_version
return serviceConfig
}
function modeConfigChanged(next: Mode) {
return JSON.stringify(stripModeMetadata(next)) !== JSON.stringify(stripModeMetadata(currentMode.value))
}
async function onStopService() {
isModeSaving.value = true
manualDisconnect.value = true
@@ -134,13 +148,14 @@ async function initWithMode(mode: Mode) {
}
url = mode.remote_rpc_address
break;
case 'service':
case 'service': {
if (!mode.config_dir || !mode.file_log_dir || !mode.file_log_level || !mode.rpc_portal) {
toast.add({ severity: 'error', summary: t('error'), detail: t('mode.service_config_empty'), life: 10000 })
return initWithMode({ ...mode, mode: 'normal' });
}
let serviceStatus = await getServiceStatus()
if (serviceStatus === "NotInstalled" || JSON.stringify(mode) !== JSON.stringify(currentMode.value)) {
const coreVersion = await getEasytierVersion()
if (serviceStatus === "NotInstalled" || modeConfigChanged(mode) || mode.installed_core_version !== coreVersion) {
mode.config_server_url = mode.config_server_url || undefined
await initService({
config_dir: mode.config_dir,
@@ -149,6 +164,7 @@ async function initWithMode(mode: Mode) {
rpc_portal: mode.rpc_portal,
config_server: mode.config_server_url,
})
mode.installed_core_version = coreVersion
serviceStatus = await getServiceStatus()
}
if (serviceStatus === "Stopped") {
@@ -157,6 +173,7 @@ async function initWithMode(mode: Mode) {
url = "tcp://" + mode.rpc_portal.replace("0.0.0.0", "127.0.0.1")
retrys = 5
break;
}
case 'normal':
url = mode.rpc_portal;
break;
-20
View File
@@ -1,20 +0,0 @@
[package]
name = "easytier-rpc-build"
description = "Protobuf RPC Service Generator for EasyTier"
version = "0.1.0"
edition.workspace = true
homepage = "https://github.com/EasyTier/EasyTier"
repository = "https://github.com/EasyTier/EasyTier"
authors = ["kkrainbow"]
keywords = ["vpn", "p2p", "network", "easytier"]
categories = ["network-programming", "command-line-utilities"]
license-file = "LICENSE"
readme = "README.md"
[dependencies]
heck = "0.5"
prost-build = "0.13"
[features]
default = []
internal-namespace = []
-1
View File
@@ -1 +0,0 @@
../LICENSE
-3
View File
@@ -1,3 +0,0 @@
# Introduction
This is a protobuf rpc service stub generator for [EasyTier](https://github.com/EasyTier/EasyTier) project.
-449
View File
@@ -1,449 +0,0 @@
extern crate heck;
extern crate prost_build;
use std::fmt;
#[cfg(feature = "internal-namespace")]
const NAMESPACE: &str = "crate::proto::rpc_types";
#[cfg(not(feature = "internal-namespace"))]
const NAMESPACE: &str = "easytier::proto::rpc_types";
/// The service generator to be used with `prost-build` to generate RPC implementations for
/// `prost-simple-rpc`.
///
/// See the crate-level documentation for more info.
#[allow(missing_copy_implementations)]
#[derive(Clone, Debug, Default)]
pub struct ServiceGenerator {
_private: (),
}
impl prost_build::ServiceGenerator for ServiceGenerator {
fn generate(&mut self, service: prost_build::Service, mut buf: &mut String) {
use std::fmt::Write;
let descriptor_name = format!("{}Descriptor", service.name);
let server_name = format!("{}Server", service.name);
let client_name = format!("{}Client", service.name);
let method_descriptor_name = format!("{}MethodDescriptor", service.name);
let mut trait_methods = String::new();
let mut weak_impl_methods = String::new();
let mut enum_methods = String::new();
let mut list_enum_methods = String::new();
let mut client_methods = String::new();
let mut client_own_methods = String::new();
let mut match_name_methods = String::new();
let mut match_proto_name_methods = String::new();
let mut match_input_type_methods = String::new();
let mut match_input_proto_type_methods = String::new();
let mut match_output_type_methods = String::new();
let mut match_output_proto_type_methods = String::new();
let mut match_handle_methods = String::new();
// generate trait default method Xxx::json_call_method match branch
let mut match_trait_json_methods = String::new();
let mut match_method_try_from = String::new();
for (idx, method) in service.methods.iter().enumerate() {
assert!(
!method.client_streaming,
"Client streaming not yet supported for method {}",
method.proto_name
);
assert!(
!method.server_streaming,
"Server streaming not yet supported for method {}",
method.proto_name
);
ServiceGenerator::write_comments(&mut trait_methods, 4, &method.comments).unwrap();
writeln!(
trait_methods,
r#" async fn {name}(&self, ctrl: Self::Controller, input: {input_type}) -> {namespace}::error::Result<{output_type}>;"#,
name = method.name,
input_type = method.input_type,
output_type = method.output_type,
namespace = NAMESPACE,
)
.unwrap();
writeln!(
weak_impl_methods,
r#" async fn {method_name}(&self, ctrl: Self::Controller, input: {input_type}) -> {namespace}::error::Result<{output_type}> {{
let Some(service) = self.upgrade() else {{
return Err({namespace}::error::Error::Shutdown);
}};
service.{method_name}(ctrl, input).await
}}"#,
method_name = method.name,
input_type = method.input_type,
output_type = method.output_type,
namespace = NAMESPACE,
)
.unwrap();
ServiceGenerator::write_comments(&mut enum_methods, 4, &method.comments).unwrap();
writeln!(
enum_methods,
" {name} = {index},",
name = method.proto_name,
index = idx + 1
)
.unwrap();
writeln!(
match_method_try_from,
" {index} => Ok({service_name}MethodDescriptor::{name}),",
service_name = service.name,
name = method.proto_name,
index = idx + 1,
)
.unwrap();
writeln!(
list_enum_methods,
" {service_name}MethodDescriptor::{name},",
service_name = service.name,
name = method.proto_name
)
.unwrap();
writeln!(
client_methods,
r#" async fn {name}(&self, ctrl: H::Controller, input: {input_type}) -> {namespace}::error::Result<{output_type}> {{
{client_name}Client::{name}_inner(self.0.clone(), ctrl, input).await
}}"#,
name = method.name,
input_type = method.input_type,
output_type = method.output_type,
client_name = service.name,
namespace = NAMESPACE,
)
.unwrap();
writeln!(
client_own_methods,
r#" async fn {name}_inner(handler: H, ctrl: H::Controller, input: {input_type}) -> {namespace}::error::Result<{output_type}> {{
{namespace}::__rt::call_method(handler, ctrl, {method_descriptor_name}::{proto_name}, input).await
}}"#,
name = method.name,
method_descriptor_name = method_descriptor_name,
proto_name = method.proto_name,
input_type = method.input_type,
output_type = method.output_type,
namespace = NAMESPACE,
).unwrap();
let case = format!(
" {service_name}MethodDescriptor::{proto_name} => ",
service_name = service.name,
proto_name = method.proto_name
);
writeln!(match_name_methods, "{}{:?},", case, method.name).unwrap();
writeln!(match_proto_name_methods, "{}{:?},", case, method.proto_name).unwrap();
writeln!(
match_input_type_methods,
"{}::std::any::TypeId::of::<{}>(),",
case, method.input_type
)
.unwrap();
writeln!(
match_input_proto_type_methods,
"{}{:?},",
case, method.input_proto_type
)
.unwrap();
writeln!(
match_output_type_methods,
"{}::std::any::TypeId::of::<{}>(),",
case, method.output_type
)
.unwrap();
writeln!(
match_output_proto_type_methods,
"{}{:?},",
case, method.output_proto_type
)
.unwrap();
write!(
match_handle_methods,
r#"{} {{
let decoded: {input_type} = {namespace}::__rt::decode(input)?;
let ret = service.{name}(ctrl, decoded).await?;
{namespace}::__rt::encode(ret)
}}
"#,
case,
input_type = method.input_type,
name = method.name,
namespace = NAMESPACE,
)
.unwrap();
write!(
match_trait_json_methods,
r#" "{name}" | "{proto_name}" => {{
let req: {input_type} = ::serde_json::from_value(json).map_err(|e| {namespace}::error::Error::MalformatRpcPacket(format!("json error: {{}}", e)))?;
let resp = self.{typed_method}(ctrl, req).await?;
Ok(::serde_json::to_value(resp).map_err(|e| {namespace}::error::Error::MalformatRpcPacket(format!("json error: {{}}", e)))?)
}}
"#,
name = method.name,
proto_name = method.proto_name,
input_type = method.input_type,
typed_method = method.name,
namespace = NAMESPACE,
)
.unwrap();
}
ServiceGenerator::write_comments(&mut buf, 0, &service.comments).unwrap();
write!(
buf,
r#"
#[async_trait::async_trait]
#[auto_impl::auto_impl(&, Arc, Box)]
pub trait {name} {{
type Controller: {namespace}::controller::Controller;
{trait_methods}
async fn json_call_method(
&self,
ctrl: Self::Controller,
method_name: &str,
json: ::serde_json::Value,
) -> {namespace}::error::Result<::serde_json::Value> {{
match method_name {{
{match_trait_json_methods}
_ => Err({namespace}::error::Error::InvalidMethodIndex(0, method_name.to_string())),
}}
}}
}}
#[async_trait::async_trait]
impl<T> {name} for ::std::sync::Weak<T>
where
T: Send + Sync + 'static,
::std::sync::Arc<T>: {name},
{{
type Controller = <::std::sync::Arc<T> as {name}>::Controller;
{weak_impl_methods}
}}
/// A service descriptor for a `{name}`.
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Default)]
pub struct {descriptor_name};
/// Methods available on a `{name}`.
///
/// This can be used as a key when routing requests for servers/clients of a `{name}`.
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
#[repr(u8)]
pub enum {method_descriptor_name} {{
{enum_methods}
}}
impl std::convert::TryFrom<u8> for {method_descriptor_name} {{
type Error = {namespace}::error::Error;
fn try_from(value: u8) -> {namespace}::error::Result<Self> {{
match value {{
{match_method_try_from}
_ => Err({namespace}::error::Error::InvalidMethodIndex(value, "{name}".to_string())),
}}
}}
}}
/// A client for a `{name}`.
///
/// This implements the `{name}` trait by dispatching all method calls to the supplied `Handler`.
#[derive(Clone, Debug)]
pub struct {client_name}<H>(H) where H: {namespace}::handler::Handler;
impl<H> {client_name}<H> where H: {namespace}::handler::Handler<Descriptor = {descriptor_name}> {{
/// Creates a new client instance that delegates all method calls to the supplied handler.
pub fn new(handler: H) -> {client_name}<H> {{
{client_name}(handler)
}}
}}
impl<H> {client_name}<H> where H: {namespace}::handler::Handler<Descriptor = {descriptor_name}> {{
{client_own_methods}
}}
#[async_trait::async_trait]
impl<H> {name} for {client_name}<H> where H: {namespace}::handler::Handler<Descriptor = {descriptor_name}> {{
type Controller = H::Controller;
{client_methods}
}}
pub struct {client_name}Factory<C: {namespace}::controller::Controller>(std::marker::PhantomData<C>);
impl<C: {namespace}::controller::Controller> Clone for {client_name}Factory<C> {{
fn clone(&self) -> Self {{
Self(std::marker::PhantomData)
}}
}}
impl<C> {namespace}::__rt::RpcClientFactory for {client_name}Factory<C> where C: {namespace}::controller::Controller {{
type Descriptor = {descriptor_name};
type ClientImpl = Box<dyn {name}<Controller = C> + Send + Sync + 'static>;
type Controller = C;
fn new(handler: impl {namespace}::handler::Handler<Descriptor = Self::Descriptor, Controller = Self::Controller>) -> Self::ClientImpl {{
Box::new({client_name}::new(handler))
}}
}}
/// A server for a `{name}`.
///
/// This implements the `Server` trait by handling requests and dispatch them to methods on the
/// supplied `{name}`.
#[derive(Clone, Debug)]
pub struct {server_name}<A>(A) where A: {name} + Clone + Send + 'static;
impl<T> {server_name}<::std::sync::Weak<T>>
where
T: Send + Sync + 'static,
::std::sync::Arc<T>: {name},
{{
pub fn new_arc(service: ::std::sync::Arc<T>) -> {server_name}<::std::sync::Weak<T>> {{
{server_name}(::std::sync::Arc::downgrade(&service))
}}
}}
impl<A> {server_name}<A> where A: {name} + Clone + Send + 'static {{
/// Creates a new server instance that dispatches all calls to the supplied service.
pub fn new(service: A) -> {server_name}<A> {{
{server_name}(service)
}}
async fn call_inner(
service: A,
method: {method_descriptor_name},
ctrl: A::Controller,
input: ::bytes::Bytes)
-> {namespace}::error::Result<::bytes::Bytes> {{
match method {{
{match_handle_methods}
}}
}}
}}
impl {namespace}::descriptor::ServiceDescriptor for {descriptor_name} {{
type Method = {method_descriptor_name};
fn name(&self) -> &'static str {{ {name:?} }}
fn proto_name(&self) -> &'static str {{ {proto_name:?} }}
fn package(&self) -> &'static str {{ {package:?} }}
fn methods(&self) -> &'static [Self::Method] {{
&[ {list_enum_methods} ]
}}
}}
#[async_trait::async_trait]
impl<A> {namespace}::handler::Handler for {server_name}<A>
where
A: {name} + Clone + Send + Sync + 'static {{
type Descriptor = {descriptor_name};
type Controller = A::Controller;
async fn call(
&self,
ctrl: A::Controller,
method: {method_descriptor_name},
input: ::bytes::Bytes)
-> {namespace}::error::Result<::bytes::Bytes> {{
{server_name}::call_inner(self.0.clone(), method, ctrl, input).await
}}
}}
impl {namespace}::descriptor::MethodDescriptor for {method_descriptor_name} {{
fn name(&self) -> &'static str {{
match *self {{
{match_name_methods}
}}
}}
fn proto_name(&self) -> &'static str {{
match *self {{
{match_proto_name_methods}
}}
}}
fn input_type(&self) -> ::std::any::TypeId {{
match *self {{
{match_input_type_methods}
}}
}}
fn input_proto_type(&self) -> &'static str {{
match *self {{
{match_input_proto_type_methods}
}}
}}
fn output_type(&self) -> ::std::any::TypeId {{
match *self {{
{match_output_type_methods}
}}
}}
fn output_proto_type(&self) -> &'static str {{
match *self {{
{match_output_proto_type_methods}
}}
}}
fn index(&self) -> u8 {{
*self as u8
}}
}}
"#,
name = service.name,
descriptor_name = descriptor_name,
server_name = server_name,
client_name = client_name,
method_descriptor_name = method_descriptor_name,
proto_name = service.proto_name,
package = service.package,
trait_methods = trait_methods,
weak_impl_methods = weak_impl_methods,
enum_methods = enum_methods,
list_enum_methods = list_enum_methods,
client_own_methods = client_own_methods,
client_methods = client_methods,
match_name_methods = match_name_methods,
match_proto_name_methods = match_proto_name_methods,
match_input_type_methods = match_input_type_methods,
match_input_proto_type_methods = match_input_proto_type_methods,
match_output_type_methods = match_output_type_methods,
match_output_proto_type_methods = match_output_proto_type_methods,
match_handle_methods = match_handle_methods,
match_trait_json_methods = match_trait_json_methods,
namespace = NAMESPACE,
).unwrap();
}
}
impl ServiceGenerator {
fn write_comments<W>(
mut write: W,
indent: usize,
comments: &prost_build::Comments,
) -> fmt::Result
where
W: fmt::Write,
{
for comment in &comments.leading {
for line in comment.lines().filter(|s| !s.is_empty()) {
writeln!(write, "{}///{}", " ".repeat(indent), line)?;
}
}
Ok(())
}
}
+3 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "easytier-web"
version = "2.6.0"
version = "2.6.4"
edition.workspace = true
description = "Config server for easytier. easytier-core gets config from this and web frontend use it as restful api server."
@@ -10,6 +10,7 @@ tracing = { version = "0.1", features = ["log"] }
anyhow = { version = "1.0" }
thiserror = "1.0"
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["rt"] }
dashmap = "6.1"
url = "2.2"
async-trait = "0.1"
@@ -52,6 +53,7 @@ clap = { version = "4.4.8", features = [
"unicode",
"derive",
"wrap_help",
"env",
] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
+16 -5
View File
@@ -13,12 +13,18 @@
"./*.css": "./dist/*.css"
},
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"codegen:proto": "node scripts/codegen-proto.mjs",
"dev": "pnpm codegen:proto && vite",
"build": "pnpm codegen:proto && vue-tsc -b && vite build",
"test": "pnpm test:config-ui && pnpm test:network-config",
"test:config-ui": "pnpm codegen:proto && vitest run --config vitest.config.ts",
"test:network-config": "pnpm build && node scripts/test-network-config.mjs",
"preview": "vite preview"
},
"dependencies": {
"@primeuix/themes": "^1.2.3",
"@protobuf-ts/runtime": "2.11.1",
"@protobuf-ts/runtime-rpc": "2.11.1",
"@vueuse/core": "^11.1.0",
"axios": "^1.13.5",
"chart.js": "^4.5.0",
@@ -33,9 +39,13 @@
},
"devDependencies": {
"@modyfi/vite-plugin-yaml": "^1.1.0",
"@protobuf-ts/plugin": "2.11.1",
"@protobuf-ts/protoc": "2.11.1",
"@types/node": "^22.8.6",
"@vitejs/plugin-vue": "^5.1.4",
"@vue/test-utils": "^2.4.11",
"autoprefixer": "^10.4.20",
"happy-dom": "16.8.1",
"postcss": "^8.4.47",
"postcss-import": "^16.1.0",
"postcss-nested": "^7.0.2",
@@ -43,10 +53,11 @@
"typescript": "~5.6.3",
"vite": "^5.4.21",
"vite-plugin-dts": "^4.3.0",
"vitest": "^2.1.9",
"vue-tsc": "^2.1.10"
},
"peerDependencies": {
"vue": "^3.5.12",
"primevue": "^4.3.9"
"primevue": "^4.3.9",
"vue": "^3.5.12"
}
}
}

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