Compare commits

...
Author SHA1 Message Date
KKRainbow 0f2f019f16 ci(core): keep matrix jobs running after transient failures
Disable fail-fast for the Core build matrix so one flaky external
download does not cancel every other platform build. This preserves
successful matrix results and makes failed jobs independently
retryable.
2026-08-13 06:40:33 +08:00
KKRainbow 86bf3df0fd fix(shared-tun): restore routed IPv6 replies
Forward route-owned packets unchanged when a member has no address
suitable for source translation. This preserves public IPv6 provider
replies while keeping cross-member source translation when an address
is available.

Keep the shared DNS route backend available in tun-only builds and
use the current error conversion in the FreeBSD IPv4 setup path.
2026-08-13 02:51:40 +08:00
KKRainbow daf30bff6e test(shared-tun): satisfy strict clippy check
Return the shared route owner count expression directly so the new root
integration helper passes the workspace -D warnings lint gate.
2026-08-13 01:59:44 +08:00
KKRainbow b5bc4a853d fix(shared-tun): close mobile routing gaps
Translate IPv6 traffic across shared mobile members, including
transport checksums and reverse-flow handling.

Normalize saved Android TUN configs and detach runtimes before the
VPN fd closes. Restore the mainline lockfile after resolving the merge.
2026-08-13 01:43:40 +08:00
KKRainbow 144e306562 Merge upstream main into shared virtual NIC work
Port shared TUN ownership, routing, mobile source dispatch, and
Magic DNS route claims onto the native runtime-host architecture.

Preserve per-member lifecycle and add focused desktop, mobile, netlink,
GUI, FFI, and root integration validation for the merged code.
2026-08-13 00:56:52 +08:00
52bb26680a fix(fake-tcp): normalize Linux packet sockets to layer 3 (#2255)
Use AF_PACKET SOCK_DGRAM so Ethernet, TUN, and point-to-point
interfaces expose the same IP payload to the BPF filter. Rebuild the
synthetic Ethernet envelope expected by fake TCP on receive and strip
it before transmitting through the cooked socket.

Bind sockets to the selected IP protocol and reject non-initial IPv4
fragments before reading TCP ports. Preserve peer MAC addresses on
Ethernet links.

Add privileged TUN and veth tests for IPv4/IPv6 receive, send, tuple
filtering, and fragment rejection, and enable them in Linux CI.

Co-authored-by: KKRainbow <443152178@qq.com>
Co-authored-by: Max Sum <4883681+Max-Sum@users.noreply.github.com>
2026-08-12 20:21:42 +08:00
KKRainbowandGitHub 0b27ac2885 feat(credentials): support managed credential synchronization (#2490)
* feat(credentials): support managed credential synchronization

Allow managed callers to upsert credentials with an exact ID, secret,
permissions, reuse policy, and expiry.

Return non-secret attributes plus a public-key fingerprint so callers can
verify relay credential consistency.

Persist imported credentials atomically and preserve identity and expiry
across restarts.

* fix(credentials): make managed upserts durable

Write the candidate credential snapshot before committing it to memory.
Propagate storage failures so controllers can retry instead of observing
false convergence.

Cover a transient storage failure to verify that memory stays unchanged
and the retry persists the credential.

* fix(credentials): atomically replace stored snapshots

Define CredentialStorage::store as an atomic replacement boundary and
use atomic-write-file in the management adapter. This keeps the last
committed credential JSON readable when a replacement fails.

Cover replacement of an existing credential snapshot and keep the
dependency scoped to the management feature.
2026-08-10 23:20:36 +08:00
23d55373a4 feat(ohos): add nearby console integration and improve UDP path selection (#2486)
* feat(ohos): complete nearby console integration

Use the core management RPC surface for ephemeral nearby deployments, harden session lifecycle and packet validation, support tunnel-to-NIC packet conversion, and timestamp HarmonyOS traffic samples.

* fix(core): prefer verified UDP hole-punch paths

Treat zero latency as unmeasured so a newly admitted UDP path cannot replace a working relay before liveness is confirmed.

---------

Co-authored-by: FrankHan <frankhan@FrankHans-Mac-mini.local>
2026-08-10 14:15:09 +08:00
KKRainbowandGitHub d375d7e455 feat(mini): add compact native EasyTier client (#2479)
Add a native EasyTier proof-of-concept binary with TCP and UDP
transports, TUN, UDP hole punching, AES-GCM, and a read-only RPC
portal.

Introduce a release-derived mini profile and musl linker policy so
x86_64, big-endian MIPS, and little-endian MIPS stay below the strict
5,000,000-byte target without UPX.
2026-08-09 19:43:30 +08:00
KKRainbowandGitHub 1e40350c89 feat(wasi): expose protobuf RPC request ABI (#2477)
* feat(wasi): expose protobuf RPC request ABI

Add an instance-scoped asynchronous RPC session backed by the shared
operation broker. Reuse the existing dispatcher and management handlers.
WASI hosts can call PeerManageRpc and ConnectorManageRpc with the same
protobuf payloads as easytier-cli.

Export ABI version, submit, take, and free functions. Bind selectors to
the WASM instance handle and keep method errors in RpcResponse. Enable
management RPC explicitly in the Go-host WASM build.

* fix(gateway): serialize UDP client eviction

Serialize UDP client admission across forwarding rules so only one
eviction can claim and wait for a released semaphore permit. Retry
when cleanup concurrently removes the selected client.

Add a multithreaded regression test for the permit handoff while the
evicted client is still referenced.

* fix(gateway): publish UDP client admission atomically

Hold the admission guard through client and response-task publication
so a concurrent eviction cannot leave an orphan task holding the slot
permit.

Open the data-plane flow before entering the critical section and extend
the multithreaded regression test across the publication window.
2026-08-07 18:34:53 +08:00
KKRainbowandGitHub e31bde1836 feat(web): add standalone WASM config generator (#2480)
Add a small Vite workspace that builds and publishes independently
of the dashboard. Reuse frontend-lib for the form and expose the
existing NetworkConfig conversions through wasm-bindgen.

Initialize the Aura theme in the standalone entry, detect the browser
language, and provide a persistent selector in the form header. Present
Generate Config and Copy Config as the page actions.

Keep the shared network secret field fluid so both form columns align.
Bundle the generator under dist/config-generator in the dashboard
artifact while preserving its standalone build output.

Build the optimized WASM module with the project and remove the
API-backed generator route from the dashboard.
2026-08-07 16:25:33 +08:00
KKRainbowandGitHub 86222771c5 fix(peer): recover from asymmetric direct connections (#2476)
Require matching pong responses before resetting consecutive liveness
failures so half-open direct connections leave the peer map.

Carry latency-first policy on relay handshakes and route replies around
stale direct peers, including handshakes started during decryption.

Store peer-center reports as atomic per-peer snapshots and include
topology costs in the digest so removals and latency updates propagate.

Drop data packets at a saturated host egress boundary instead of
blocking the shared peer packet router and shutdown path.

Add regressions for asymmetric traffic, relay ACK routing, peer-center
invalidation, and bounded host egress.
2026-08-07 10:51:15 +08:00
d114cdd20f fix(web-client): time out stalled config-server dials (#2461)
Co-authored-by: 225284228a-droid <239500008+225284228a-droid@users.noreply.github.com>
2026-08-04 10:08:57 +08:00
Chenx DustandGitHub df874b85be refactor(core): use linearizable lazy token bucket (#2421)
Replace periodic refill tasks with on-demand accounting to avoid waking
idle token buckets.

Keep balance, refill time, and fractional credit in one locked state so
concurrent consumers cannot observe partially published refills or
exceed the configured burst capacity. Track credit in nanoseconds and
discard excess credit at capacity to preserve precise limiter behavior.

Use a one-second default burst capacity to preserve the existing
limiter behavior while supporting explicit capacity configuration. Keep
limiter capacity and fill rate in a local config instead of an unused
protobuf message.

Charge only logical EasyTier data payload, unwrap foreign network
packets before accounting, and leave control traffic outside the
limiter. Reject forged payload lengths by accounting from actual packet
boundaries.

Split oversized blocking consumes into capacity-sized chunks and cover
concurrency, refill precision, burst caps, payload accounting, and
bandwidth integration behavior.
2026-08-04 10:08:37 +08:00
8d475dc3fc fix(tunnel): preserve websocket cleanup after send errors (#2468)
Co-authored-by: FrankHan <frankhan@FrankHans-Mac-mini.local>
2026-08-03 10:15:42 +08:00
韩嘉乐andGitHub 40c857748f feat(ohos): 接入 Pro 运行时并自动化 HAR 交付 (#2462) 2026-08-01 16:35:14 +08:00
KKRainbowandGitHub afbba5d928 refactor(core): migrate packet processing from pnet to smoltcp (#2456)
Replace pnet_packet parsing and mutation across gateway packet paths with the existing smoltcp wire APIs. Preserve length validation, fragmentation classification, TCP flags, and checksum behavior while removing the core pnet_packet feature dependency.
Reject stale non-initiator OSPF sync sessions: only initiator requests may create missing sessions, and a rejection clears the old initiator role only when the remote session generation is unchanged. This fixes an unbounded RPC storm caused by a delayed route sync recreating a session after both peers relinquished the initiator role, with regression tests for session creation and response reordering.
2026-08-01 10:57:23 +08:00
fc3914baf5 Require a full config server URL (#2459)
Co-authored-by: 刚刚 <239500008+225284228a-droid@users.noreply.github.com>
2026-07-31 16:47:40 +08:00
zyflypzlcandGitHub 4923a13c5d 增加easytier_ffi对Windows7的兼容 (#2458) 2026-07-30 20:32:08 +08:00
KKRainbowandGitHub d55e63b88e perf(wasi): optimize data plane and extend host ABI to v3 (#2455)
Overhaul the WASI guest data plane for throughput and add the host
capabilities it relies on. The externally driven Tokio runtime now
runs its timer pre-turn only when a tracked deadline has expired,
and all WASI-reachable timers (STUN, port mapping, WebClient, UDP
flow cleanup) go through the portable time facade so conditional
timer driving cannot starve them.

Data plane:

- Move read/write deadlines onto TCP and UDP resources with one ABI
  setter per direction, reuse a single expiration timer per
  resource, and drop timeout arguments from the four hot data-plane
  submissions (ABI v3). Checked absolute instants treat
  unrepresentable finite timeouts as unbounded instead of panicking.
- Batch host traffic: vectored TCP frame writes combine queued
  slices into one host operation, and reads request a bounded 64
  KiB while retaining excess bytes in the stream buffer.
- Complete TCP writes inside the guest with cancellation-safe
  writes, reporting the completed prefix before honoring
  cancellation or timeout so hosts never replay bytes.
- Repoll smoltcp egress immediately on zero poll delay, enlarge
  virtual UDP receive queues to 128 KiB payload with 128 metadata
  slots, and bound UDP session receive buffers to 8 KiB plus one
  byte while keeping oversized-datagram detection.

Host integration:

- Add optional algorithm-neutral AEAD seal/open imports with the
  ring backend as fallback, and pin the ring AES-128-GCM wire vector
  so the Go host stays interoperable.
- Forward instance events to hosts through one best-effort,
  synchronous, non-blocking import.
- Add a repository-owned build entry point for the Go host artifact:
  Binaryen 131 at -O4 with cached, SHA-256-verified official
  archives.
2026-07-28 00:29:08 +08:00
KKRainbowandGitHub 7b506e25a7 perf(data-plane): reduce per-packet synchronization overhead (#2453)
* perf(stats): avoid per-update clock reads

Perf profiles show quanta::get_now consuming 2.9-4.3% of data-plane
CPU because every counter update refreshes a high-resolution timestamp.

Track metric activity with the existing 60-second cleanup cadence instead.
Relaxed 32-bit epochs preserve the three-minute retention window, support
32-bit targets, and remove repeated clock reads from packet processing.

* perf(data-plane): reduce per-packet synchronization

Perf profiles showed per-packet config Arc cloning, bounded-channel
permit futures, duplicate peer lookups, and default connection UUID
lookups consuming CPU in both TCP and UDP data paths.

Borrow stable config snapshots, use nonblocking channel fast paths with
the existing backpressure fallback, reuse direct peer lookups, and cache
the selected connection while preserving close and reselection behavior.

Add focused tests for channel backpressure and cached connection
invalidation.

* fix(peer): serialize default connection cache updates

The profile-guided default connection cache could republish a connection
after the close task removed it, leaving a stale cache while another
connection remained live.

Serialize only cache-miss selection/publication and connection removal.
The per-packet cache-hit path remains lock-free, while close and selection
can no longer race to resurrect a removed connection.
2026-07-27 21:32:57 +08:00
KKRainbowandGitHub 7fb42c3b73 perf(data-plane): restore native throughput after host portability (#2452)
* perf(core): make data-plane idle check constant time

Avoid scanning every DashMap shard for each peer packet when no data-plane flows are active.

Publish the flow count before insertion and release it after removal so an Acquire load is a safe O(1) idle signal. Reject count overflow and underflow instead of silently saturating.

* test(perf): add repeatable two-node netns benchmark

Create isolated underlay namespaces, pin both EasyTier cores and iperf3 endpoints, and measure a single TCP flow in both directions over either UDP or TCP peer transport.

Keep every iperf3 JSON result and emit directional medians while cleaning up processes and namespaces on every exit path.

* perf(tcp): preserve native owned stream halves

Let each VirtualTcpSocket adapter consume itself into independent read and write halves. Portable adapters retain the generic shared split as a default.

Use lock-free Tokio owned halves for native TCP and Unix streams so tunnel I/O no longer takes the generic split mutex on every poll. Cover full-duplex traffic and write-half shutdown.

* perf(packet): preserve ownership across the Host seam

Introduce an opaque, move-only HostPacket that retains core packet storage while exposing only the raw IP payload. Clear private headers before handing storage back to a native TUN adapter.

Use an ownership-preserving bounded channel for native ingress and egress. Keep explicit copy adapters for Vec and WASI boundaries, and verify allocation identity, backpressure, shutdown, and end-to-end delivery.

* perf(udp): preserve packet ownership through sessions

Carry EasyTier tunnel packets through UDP session queues as owned values. Reuse the existing tunnel header for session framing instead of copying payloads into a second packet and rebuilding them on receive.

Keep completion delivery for the public datagram socket API while removing the unused completion channel from streaming tunnel sends. Avoid the unconditional receive-side clone before QUIC routing is known.

* perf(peer): publish packet filters as immutable snapshots

Replace per-packet async and synchronous registry locks with ArcSwap snapshots. Permanent filters now need no activity checks, while managed registrations retain explicit acquire/release visibility.

Closing a managed registration marks it inactive before atomically removing it. Existing snapshots keep in-flight filters alive, and registration mutations prune inactive entries while preserving newest-first order.

* perf(instance): give native hosts direct packet egress

Let the core create one bounded HostPacket channel and transfer its receiver directly to a PacketEgressHost during startup. Native TUN runtimes now consume that receiver without the intermediate PacketSink channel and forwarding task.

Keep PacketSinkEgress as the compatibility adapter for callback and test hosts, and make receiver installation one-shot across desktop, mobile, and disabled runtimes.

* perf(crypto): restore accelerated native AEAD backends

Move Ring and OpenSSL implementations behind the core Encryptor seam.
Portable builds continue selecting only supported backends.

Restore historical precedence: OpenSSL, Ring, then RustCrypto. Keep
backend availability consistent across secure transports and cover
fixed-nonce wire compatibility between implementations.

* perf(udp): receive native datagrams into owned buffers

Extend the portable UDP socket seam with an owned-datagram receive path.
Keep a compatible default for portable hosts. Native Unix sockets write
recvmsg output directly into the final BytesMut allocation.

This removes the per-packet stack-to-heap copy introduced by the portable
socket boundary without exposing native socket resources to core.

* perf(data-plane): remove portable hot-path overhead

Restore native throughput lost while generalizing the host and UDP
session layers.

Read packet policy once per send, update traffic counters through
registry guards, and preserve packet ownership while UDP dispatch
borrows stable session state.

Move UDP shutdown monitoring into a control task so forwarding avoids
a select future per packet. Bound native datagram storage to 8 KiB,
reject oversized sends, and drop truncated Unix receives.

Keep accelerated AEAD selection warning-free when portable crypto
features are also built. Cover session bounds, truncation, and idle
shutdown with regression tests.

* fix(udp): preserve portable datagram receive semantics

Keep the public portable receive capacity at the theoretical UDP
maximum instead of silently shrinking it to the native fast-path limit.

Apply the 8 KiB session boundary after a complete portable receive,
so Windows cannot turn an oversized datagram into a fatal listener
error and other adapters cannot dispatch a truncated prefix.

Cover dropping an oversized packet while the same portable socket
continues to deliver the following valid datagram.

* fix(ci): align feature gating with backend selection

Compile the Ring implementation in production only when OpenSSL is not
selected, while retaining it for cross-backend unit tests.

Remove stale test imports and assert UDP dispatch results so the strict
workspace Clippy job passes without suppressing diagnostics.
2026-07-26 22:54:43 +08:00
KKRainbowandGitHub dc11298558 ci: sign and notarize macOS GUI builds (#2418) 2026-07-26 15:44:19 +08:00
KKRainbowandGitHub 021f523431 refactor(core): separate portable core from native runtime (#2451)
Create easytier-core as the portable owner of configuration,
connectivity, tunnels, peer and routing state, gateways, management,
the data plane, and instance lifecycle. Keep operating-system
integration, native protocol engines, process startup, and presentation
in easytier behind explicit Host capability adapters.

Create easytier-proto to own schemas, generated RPC types, descriptors,
and feature-scoped protocol slices. Remove runtime protobuf reflection
from core while preserving unknown route-peer fields across forwarding.

Normalize instance construction through CoreInstance, CoreHostAdapters,
CoreProcessRuntime, and InstanceManager. Make the runtime config store
the only authoritative mutable configuration after startup.

Move the portable TCP/UDP data plane into core and extract a generic
OperationBroker for completion, cancellation, disposal, and capacity
accounting. Expose the session-based FFI v2 completion API and keep the
WASI guest ABI, wire schemas, and adapters with core.

Migrate CLI, GUI, web, FFI, Android JNI, OHOS, uptime, and mobile
consumers to the shared manager and core state. Add explicit user/web
config ownership and revision-aware web reconciliation.

Preserve configuration, wire, and management behavior while fixing
regressions discovered by the full platform and integration matrix:

- inherit advertised relay capabilities in foreign networks;
- refresh OSPF peer state immediately after runtime config changes;
- restore CLI GlobalCtx event output without forcing GUI logging;
- retain legacy encryption names and standalone RPC tunnel metadata;
- restore ICMP host composition and fragmented UDP handling;
- use portable 64-bit atomics on 32-bit MIPS targets; and
- retain discarded operations until late cancellation completes.

Validate the refactor across 45 GitHub checks, including Linux, macOS,
Windows, FreeBSD, web, GUI, Android, OHOS, feature profiles, and
three-node and subnet-proxy integration tests.

BREAKING CHANGE: internal Rust module paths are not preserved. Legacy
native data-plane APIs are replaced by the session-based FFI v2 API.
The dedicated Android data-plane wrapper is removed.
2026-07-26 15:41:55 +08:00
Moder StevenandGitHub 346f32d3d0 fix(web): scope GET /api/v1/sessions to the authenticated user (#2445)
handle_list_all_sessions returned client_mgr.list_sessions(), which
iterates user_clients_map across ALL users and returns every session's
StorageToken (token, client_url, machine_id, user_id). The handler is
mounted under login_required! but performed no per-user authorization:
it fetched get_group_permissions() only to println! the result, then
returned the full cross-user list. Any authenticated user could read
every other user's device token and public client_url.

Scope the result to the caller by adding
Storage::list_user_client_tokens(user_id) /
ClientManager::list_sessions_by_user_id(user_id), mirroring the existing
per-user pattern in handle_get_summary (list_machine_by_user_id). Also
drop the leftover debug println! and the unwrap() on the current user
(return 401 instead).
2026-07-20 13:47:15 +08:00
fanyangandGitHub f24735a86f perf: reduce packet buffer slicing churn (#2381)
* bench: add packet bytes extraction Criterion benchmark

Adds a Criterion benchmark under easytier/benches/ covering
ZCPacket::payload_bytes and tunnel_payload_bytes at 1280/4096-byte payload
sizes, using iter_batched so ZCPacket construction stays in the setup phase
and is excluded from the timed region.

- Register the [[bench]] entry in easytier/Cargo.toml.
- Document the bench and PACKET_BYTES_* env vars in benches/README.md.

* perf: reduce packet buffer slicing churn

Replace BytesMut::split_off with Buf::advance in ZCPacket bytes
extraction paths (payload_bytes, tunnel_payload_bytes, convert_type,
drop_foreign_header) and in TunZCPacketToBytes, and simplify the
copy_from_slice in new_from_payload.

When the buffer is in its unique (VEC) representation, split_off promotes
it to the shared (ARC) representation, allocating a Shared control block
and bumping the refcount on every call, and pins the buffer in shared
mode. advance only mutates the in-place ptr/len/cap fields, avoiding that
allocation/refcount churn on the TX hot path. The byte data itself is not
copied by either path.
2026-07-01 23:19:34 +08:00
7756a15cbe feat: add interface address fallback for IPv6 prefix detection (DHCPv6 IA_NA / SLAAC) (#2334)
feat: add IPv6 prefix fallback and NDP proxy for SLAAC/IA_NA

Add interface address fallback for IPv6 prefix detection when
route-based detection fails (no delegated prefix on LAN). Covers
DHCPv6 IA_NA / SLAAC where prefix is assigned to WAN without PD.
Uses getifaddrs() and Ipv6Inet to handle host bits.

Add NDP proxy sync for on-link prefixes. ISP router uses NDP to
resolve MACs; EasyTier /128 addresses on tun0 won't answer on
physical WAN. Periodic sync (30s) reads /128 routes via netlink
and manages proxy neigh entries. Auto-enables proxy_ndp sysctl.
Returns WAN ifindex from detection; static NDP_WAN_IFINDEX for
sync task. Spawns from provider reconcile when auto-detection
enabled.

Add tests: fallback default route interface, on-link NDP proxy,
interface prefix selection.

Fixes #2333

Co-authored-by: ririyeye <200610237@qq.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: KKRainbow <443152178@qq.com>
2026-07-01 12:34:46 +08:00
fanyangandGitHub 741460e1e4 bench: add Criterion TX throughput benchmark (#2397) 2026-06-30 22:10:34 +08:00
fanyangandGitHub 13412895c5 refactor(core): remove hotpath profiling (#2394)
hotpath adds noticeable overhead on hot paths, so remove the optional
profiling integration while keeping direct quanta::Instant timing.
2026-06-30 09:50:27 +08:00
KKRainbowandGitHub 425a24273b fix: preserve secure relay sessions during cleanup (#2393)
* fix: skip secure relay packets in peer conn filter
* fix: keep active secure relay sessions during GC
2026-06-30 00:04:17 +08:00
KKRainbowandGitHub 4e61612944 fix(web): preserve managed config and status compatibility (#2389)
Fix web/frontend compat bugs in managed config & runtime status

- Preserve [[peer]].peer_public_key when TOML configs round-trip
  through the web/managed NetworkConfig path
- Keep old peer_urls clients working while adding structured peer
  metadata for new clients
- Make frontend protobuf JSON normalization preserve omitted-field
  semantics instead of turning missing data into misleading defaults
- Harden runtime status rendering against omitted or string-encoded
  backend fields
- Expose peer-route feature flags in the web status UI
2026-06-29 12:40:04 +08:00
KKRainbowandGitHub 15e5d89f70 Fix SOCKS5 port forwarding for modified peer data packets (#2391)
Fixes SOCKS5/port-forward handling for peer data packets 
whose source endpoint was rewritten by the KCP or QUIC proxy path.

Keep SOCKS5 entry accounting consistent by centralizing insert/remove 
operations, decrementing only for actual removals, avoiding underflow, 
and resetting counts when entries are retained or cleared after IPv4 changes.
2026-06-29 10:24:57 +08:00
KKRainbowandGitHub 46f1b57367 perf(ipv6_hole_punch): handle multiple ipv6 public ip correctly (#2387)
This PR fixes IPv6 UDP hole punching for peers with multiple public IPv6
addresses by adding two RPC signals:

 - connector_addrs: connector-side candidate public IPv6 socket addresses
that the remote peer should punch back to.

 - preferred_src_ipv6: remote listener IPv6 address that the remote peer
should use as the UDP source when sending hole-punch packets back.
Together, these let the connector try all usable local IPv6 candidates
while keeping the remote punch-back

packet sourced from the same IPv6 address that the connector is dialing.
2026-06-28 20:42:16 +08:00
KKRainbowandGitHub 9cb3833216 perf(easytier-web): improve easytier-web webhook performance (#2383)
* feat(web): reconcile managed config revisions
* feat(web): cache managed runtime configs per session
* test: cover managed web config delivery
2026-06-28 13:14:40 +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
sijie.sun ba86e47cc6 fix: adapt mobile ffi tun fd API 2026-06-16 20:31:22 +08:00
sijie.sun d742fa34aa refactor: simplify shared virtual nic helpers 2026-06-16 12:46:28 +08:00
sijie.sun 800c840bb5 shared-tun: preserve member ownership on mobile
Introduce shared NIC source ownership and dispatcher handling so a
single dev_name can be shared by multiple tun-enabled instances while
keeping per-member IP and route claims distinct.

Pass Android VpnService fd registration with per-instance source and
route claims. Keep the VPN address list limited to real member
addresses and allow AF_INET6 without installing hidden fd00::1.

Invalidate dispatcher flow and NAT state when source ownership changes
or a member unregisters. Avoid rewriting non-first IPv4 fragment
payloads, and adjust fragmented TCP/UDP checksums without recomputing
over partial fragment bodies.

Preserve source-owner routing for equal-prefix route conflicts, keep
ICMP echo NAT entries distinct by echo id, and retry stale flow-owner
send failures from the original packet. Only record NAT state after a
translated packet is accepted by its member.

Apply Linux IPv4 route preferred-source hints for shared routes and keep
route repair paths source-aware. Keep Darwin ifcfg access scoped to
cleanup-only paths where netns is not available.
2026-06-16 02:16:25 +08:00
韩嘉乐andGitHub 16b666ad25 fix: route_update message is not lag (#2355) 2026-06-16 00:00:48 +08:00
sijie.sun ca17e856a6 fix: tighten shared virtual nic lifecycle
Key shared virtual NIC registry entries by both netns and device
name. Instances in different namespaces can no longer reuse the same
underlying device by accident.

Store each member's configured MTU separately and publish it as
the initial MTU claim after device creation. Later members no longer
inherit only the first member's MTU.

Shut down the dispatcher when the last current member leaves, and
invalidate the shared NIC if member cleanup fails. The registry entry
remains available for a later clean recreate.

Keep Magic DNS fake-IP routes owned by shared TUN members through
the shared backend. Stopping one member no longer removes the route
while another shared member still owns it, and Linux retained routes
are re-applied after address deletion.

Extend the shared TUN Magic DNS integration test to drop one shared
member first, verify DNS still works through the remaining member,
and then verify final cleanup after the last member exits.
2026-06-15 01:34:29 +08:00
sijie.sun d87624b353 fix: address shared tun ci failures 2026-06-14 23:14:45 +08:00
sijie.sun a7ab60e0e4 fix: support android shared tun fd groups
Android previously treated setTunFd as a single-instance update, and the
VpnService plugin could only expose one IPv4 address. That made shared
TUN members disable each other or leave only one address configured.

Group enabled Android TUN instances by shared dev_name, send the fd to
every compatible member, and only disable incompatible TUN users. Build
the Android VPN request from the whole running shared group and pass
every IPv4 address to VpnService.

The shared mobile dispatcher now owns current fd device state on a
process-level runtime. New setTunFd calls replace that state even when
the raw fd number is reused, and mobile TUN read/write/create failures
rebuild with backoff while preserving member registrations.

Protect shared member cleanup with per-registration ownership tokens, so
old async cleanup cannot unregister a recreated member or remove its
source claims. Mobile source addresses are registered in the dispatcher
without applying OS ifcfg changes, so Android-originated packets return
through the owning instance.

When one shared member stops while another remains, notify the frontend
to recalculate the VpnService config instead of leaving stale addresses
and routes. Serialize Android VpnService config recalculation so stale
async events cannot overwrite newer shared-group state.

If one shared member is not ready, rebuild from the healthy members and
retry the missing member later. If no healthy member remains, stop the
Android VPN service instead of keeping stale routes active.
2026-06-14 16:24:08 +08:00
Luna YaoandGitHub 8909e88484 do not panic when fail to parse flags (#2349) 2026-06-14 13:12:20 +08:00
sijie.sun 3d0d2bed9e fix: preserve macos shared tun ipv4 aliases 2026-06-14 11:38:12 +08:00
sijie.sun 7ef023fcdd test: cover shared tun dynamic proxy routes
Cover shared tun duplicate proxy CIDR failover and runtime proxy
CIDR add/remove through netns integration tests.

Start IP proxy from config patch events so a node that did not have
proxy CIDRs at startup can serve a later proxy network patch.

Ignore NotFound for shared NIC remove-side ifcfg cleanup so member
teardown does not poison shared owner state when the OS item is
already gone.

Add dispatcher coverage for TUN read failure invalidation and member
close notification.
2026-06-14 02:37:19 +08:00
sijie.sun 69d476ad13 test: cover shared tun subnet proxy paths
Add a shared tun subnet proxy topology with shared members on both
the source and destination sides. The test exercises ICMP, TCP
subnet proxy, and UDP subnet proxy against a real netns target.

Add focused KCP and QUIC TCP proxy variants that verify the selected
TcpProxyConnect transport label. The helper waits for real proxy CIDR
reachability before running transport-specific checks to avoid racing
route installation.
2026-06-14 01:26:19 +08:00
sijie.sun cf3fcb75b5 test: cover shared tun magic dns in netns
Add an end-to-end shared tun Magic DNS test with two members
sharing one device in the same namespace. The test checks real
netns ping paths and queries the Magic DNS fake IP through dig.

Teach DnsRunner to pass the instance netns into
MagicDnsServerInstance so fake DNS route add/remove operations run
where the tun device exists. Keep the existing constructors as
wrappers so current callers stay source-compatible.
2026-06-14 01:26:19 +08:00
sijie.sun 17195a5cc4 fix: dispatch shared tun packets by source owner
Track each shared tun member's claimed IPv4 and IPv6 addresses in
the dispatcher.

When a packet arrives from the shared device, keep the existing flow
affinity lookup first, then route by source address owner before
falling back to an arbitrary member. This keeps public IPv6 auto
traffic on the member that owns the leased address while preserving
the simple local-origin fallback.

Migrate high-value shared tun coverage from the backup branch: real
member communication, no_tun isolation, proxy CIDR forwarding, and
public IPv6 auto through a shared device.
2026-06-14 01:26:19 +08:00
sijie.sun 22bcfebe3e test: cover shared nic instance selection
Add focused tests for Instance nic context creation without creating a
real tun device.

Verify empty dev_name keeps the dedicated backend, while matching
shared dev_name values reuse the same shared virtual nic and get fresh
member ids on each recreation.
2026-06-14 01:26:19 +08:00
sijie.sun 47a2fb19a6 feat: wire shared nic into instance setup
Route normal, DHCP, and mobile nic creation through one helper that
selects the shared backend when dev_name is configured.

Keep dedicated nic creation as the default for configs without a
dev_name, and keep no_tun handling in the existing callers.

Store a process-wide shared virtual nic registry behind each instance
so networks with the same dev_name attach to the same shared device.
2026-06-14 01:26:19 +08:00
sijie.sun 62e43855cb feat: support shared nic mobile fd
Create the shared virtual nic dispatcher from the mobile tun fd.

Later shared members attach through their ring tunnel and do not
consume another fd. Dedicated mobile nic creation stays unchanged.

Fix mobile no-magic-dns cfg so the mobile tun path checks without
enabling magic-dns.
2026-06-14 01:26:19 +08:00
sijie.sun 5efbf4853a feat: apply shared nic member ifcfg
Route shared nic IP and route configuration through member claims.
Repeated addresses or routes now touch the OS device only when the
first owner appears, and are removed only after the last owner leaves.

Forward NicBackend shared operations to the member claim layer.
Dynamic proxy and public IPv6 route updates now go through NicBackend,
so they use the same ownership merge behavior.

Clean member claims when shared member registration is dropped. Keep
raw ifcfg access for non-Linux platform cleanup only, and document
that this raw path does not carry the netns guard.
2026-06-14 01:26:19 +08:00
sijie.sun 3e0bde68b8 perf: bound shared nic flow eviction
Replace full flow table clears with a small FIFO order queue so overflow evicts only older flow ownership hints.

Keep lookup read-only on the packet hot path and prune stale queue entries when a shared member unregisters.
2026-06-14 01:26:19 +08:00
sijie.sun bda06ebb61 perf: speed up shared nic flow lookup
Parse shared virtual nic flow keys directly from IP payload bytes instead of building pnet packet wrappers on the dispatcher hot path.

Use a local HashMap for flow owner lookup and clear the bounded table on overflow instead of removing a BTreeMap entry by key order.
2026-06-14 01:26:19 +08:00
sijie.sun be0859aca6 refactor: keep shared dispatcher state local
Move shared member and flow ownership state into the dispatcher task.

Use control messages for member register and unregister events.

Keep the member table lock off the packet forwarding path.
2026-06-14 01:26:19 +08:00
sijie.sun b2f1b37336 refactor: add shared virtual nic dispatcher
Add the shared NIC dispatcher that owns the underlying VirtualNic tunnel.

Connect member ring tunnels through a private member table.

Track reverse flow ownership from packets written by a member.

Replies read from the shared device go back to the same member.

Packets without an owner fall back to any active member.

Mark the shared NIC invalid when the underlying tunnel closes or fails.

Notify members so their NicCtx can rebuild.
2026-06-14 01:26:19 +08:00
sijie.sun 583fb45939 refactor: reuse ring tunnel for shared members
Use create_ring_tunnel_pair for member-level shared NIC tunnels.

That replaces the custom packet stream and sink implementation.

Keep one side of each ring tunnel in SharedVirtualNic's member map.

Later dispatcher code can connect those endpoints to the shared device.
2026-06-14 01:26:19 +08:00
sijie.sun a343a907a9 refactor: add shared nic ctx constructor
Add a shared NicCtx constructor that obtains a SharedVirtualNicMember from the registry and wraps it in NicBackend::Shared.

Keep NicCtx::new on the dedicated backend path so existing runtime behavior does not change before the shared tunnel implementation is ready.
2026-06-14 01:26:19 +08:00
sijie.sun b1ac64f40f refactor: route nic ctx through nic backend
Replace NicCtx's direct VirtualNic field with NicBackend while keeping the constructor on the dedicated backend path.

Move existing device creation, ifcfg lookup, IP assignment, and public IPv6 updater calls through backend helpers so shared mode can be wired in a later commit without changing the dedicated flow.
2026-06-14 01:26:19 +08:00
sijie.sun 122fb9b54e refactor: add shared nic backend skeleton
Introduce SharedVirtualNicMember as the per-instance handle for a shared virtual NIC and add a registry helper to create members.

Add NicBackend so NicCtx can later choose between dedicated VirtualNic and shared member-backed tunnel creation without changing the existing dedicated path.
2026-06-14 01:26:19 +08:00
sijie.sun 09780c1698 refactor: add shared virtual nic registry
Add a small registry keyed by dev_name so shared-mode setup can reuse one SharedVirtualNic per device name.

Track invalid shared NICs through a shared flag and replace them on the next get_or_create call.
2026-06-14 01:26:19 +08:00
sijie.sun e533b48244 refactor: decouple virtual nic from global ctx
Move TUN-specific configuration into VirtualNicConfig so VirtualNic and SharedVirtualNic no longer depend on ArcGlobalCtx.

Keep global context side effects in NicCtx, including the Windows generated device-name writeback.
2026-06-14 01:26:19 +08:00
sijie.sun c0bd050742 add shared virtual nic 2026-06-14 01:26:19 +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
661 changed files with 147888 additions and 56922 deletions
+8
View File
@@ -9,6 +9,14 @@ rustflags = ["-C", "link-arg=-fuse-ld=mold"]
[target.'cfg(all(windows, target_env = "msvc"))']
rustflags = ["-C", "target-feature=+crt-static"]
[target.wasm32-unknown-unknown]
rustflags = [
"-C",
"opt-level=z",
"--cfg",
'getrandom_backend="wasm_js"',
]
# region
# region CI
+14 -6
View File
@@ -33,10 +33,24 @@ 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
with:
token: ${{ inputs.token }}
build-filter: ${{ inputs.pnpm-build-filter }}
- name: Install GUI dependencies (Linux)
@@ -82,9 +96,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 }}
+29 -2
View File
@@ -3,6 +3,9 @@ author: Luna
description: 'Setup Node.js, pnpm, and install dependencies'
inputs:
token:
description: 'GitHub token, used by setup-protoc action'
required: false
build-filter:
description: 'The filter argument for pnpm build (e.g. ./easytier-web/*)'
required: false
@@ -11,6 +14,22 @@ inputs:
runs:
using: "composite"
steps:
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: 1.95
target: wasm32-unknown-unknown
cache: false
rustflags: ''
- uses: taiki-e/install-action@v2
with:
tool: wasm-pack
- uses: arduino/setup-protoc@v3
with:
version: '35.1'
repo-token: ${{ inputs.token }}
- name: Setup Node.js
uses: actions/setup-node@v5
with:
@@ -41,8 +60,16 @@ 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
- name: Bundle config generator with web frontend
shell: bash
run: |
if [ -f easytier-web/frontend/dist/index.html ] && [ -f easytier-web/config-generator/dist/index.html ]; then
mkdir -p easytier-web/frontend/dist/config-generator
cp -R easytier-web/config-generator/dist/. easytier-web/frontend/dist/config-generator/
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"]
+7 -3
View File
@@ -36,7 +36,7 @@ jobs:
concurrent_skipping: 'same_content_newer'
skip_after_successful_duplicate: 'true'
cancel_others: 'true'
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", ".github/workflows/core.yml", ".github/actions/**", "easytier-web/**"]'
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-core/**", "easytier-proto/**", ".github/workflows/core.yml", ".github/actions/**", "easytier-web/**"]'
build_web:
runs-on: ubuntu-latest
needs: pre_job
@@ -47,6 +47,7 @@ jobs:
- name: Setup Frontend Environment
uses: ./.github/actions/prepare-pnpm
with:
token: ${{ github.token }}
build-filter: './easytier-web/*'
- name: Archive artifact
@@ -57,7 +58,7 @@ jobs:
easytier-web/frontend/dist/*
build:
strategy:
fail-fast: true
fail-fast: false
matrix:
include:
- TARGET: x86_64-unknown-linux-musl
@@ -157,6 +158,9 @@ jobs:
- uses: mlugg/setup-zig@v2
if: ${{ contains(matrix.OS, 'ubuntu') }}
with:
version: 0.16.0
use-cache: true
- uses: taiki-e/install-action@v2
if: ${{ contains(matrix.OS, 'ubuntu') }}
@@ -227,7 +231,7 @@ jobs:
*) UPX_ARCH="amd64" ;;
esac
UPX_VERSION=5.1.1
UPX_VERSION=4.2.4
UPX_PKG="upx-${UPX_VERSION}-${UPX_ARCH}_linux"
curl -L "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/${UPX_PKG}.tar.xz" -s | tar xJvf -
cp "${UPX_PKG}/upx" .
+1 -1
View File
@@ -11,7 +11,7 @@ on:
image_tag:
description: 'Tag for this image build'
type: string
default: 'v2.6.3'
default: 'v2.6.4'
required: true
mark_latest:
description: 'Mark this image as latest'
+81 -2
View File
@@ -35,7 +35,7 @@ jobs:
concurrent_skipping: 'same_content_newer'
skip_after_successful_duplicate: 'true'
cancel_others: 'true'
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-gui/**", ".github/workflows/gui.yml", ".github/actions/**", "easytier-web/frontend-lib/**"]'
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-core/**", "easytier-gui/**", ".github/workflows/gui.yml", ".github/actions/**", "easytier-web/frontend-lib/**"]'
build-gui:
strategy:
fail-fast: true
@@ -117,13 +117,92 @@ jobs:
find "./easytier/third_party/${ARCH_DIR}" -maxdepth 1 -type f \( -name "*.dll" -o -name "*.sys" \) -exec cp {} ./easytier-gui/src-tauri/ \;
fi
- name: Validate macOS signing secrets
if: ${{ contains(matrix.GUI_TARGET, 'darwin') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
missing=()
for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID; do
if [[ -z "${!name}" ]]; then
missing+=("$name")
fi
done
if (( ${#missing[@]} )); then
printf 'Missing macOS signing secret(s): %s\n' "${missing[*]}" >&2
exit 1
fi
- name: Build GUI
if: ${{ matrix.GUI_TARGET != '' }}
if: ${{ matrix.GUI_TARGET != '' && (!contains(matrix.GUI_TARGET, 'darwin') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)) }}
uses: tauri-apps/tauri-action@v0
with:
projectPath: ./easytier-gui
args: --verbose --target ${{ matrix.GUI_TARGET }}
- name: Build GUI (signed and notarized)
if: ${{ contains(matrix.GUI_TARGET, 'darwin') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
timeout-minutes: 60
uses: tauri-apps/tauri-action@v0
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with:
projectPath: ./easytier-gui
args: --verbose --target ${{ matrix.GUI_TARGET }}
- name: Notarize and staple macOS DMG
if: ${{ contains(matrix.GUI_TARGET, 'darwin') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
timeout-minutes: 45
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
set -euo pipefail
dmg_dir="./target/$GUI_TARGET/release/bundle/dmg"
if [[ ! -d "$dmg_dir" ]]; then
printf 'macOS DMG directory not found: %s\n' "$dmg_dir" >&2
exit 1
fi
dmgs=()
while IFS= read -r dmg; do
dmgs+=("$dmg")
done < <(find "$dmg_dir" -maxdepth 1 -type f -name "*.dmg" | sort)
if (( ${#dmgs[@]} == 0 )); then
printf 'No macOS DMG found in %s\n' "$dmg_dir" >&2
exit 1
fi
for dmg in "${dmgs[@]}"; do
printf 'Verifying signed DMG: %s\n' "$dmg"
codesign --verify --verbose=4 "$dmg"
codesign -dv --verbose=4 "$dmg"
printf 'Notarizing DMG: %s\n' "$dmg"
xcrun notarytool submit "$dmg" \
--apple-id "$APPLE_ID" \
--password "$APPLE_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--wait \
--timeout 40m
printf 'Stapling DMG: %s\n' "$dmg"
xcrun stapler staple "$dmg"
xcrun stapler validate "$dmg"
done
- name: Collect artifact
run: |
mkdir -p ./artifacts/objects/
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
concurrent_skipping: 'same_content_newer'
skip_after_successful_duplicate: 'true'
cancel_others: 'true'
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-gui/**", "tauri-plugin-vpnservice/**", ".github/workflows/mobile.yml", ".github/actions/**"]'
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-core/**", "easytier-gui/**", "tauri-plugin-vpnservice/**", ".github/workflows/mobile.yml", ".github/actions/**"]'
build-mobile:
strategy:
fail-fast: true
+157 -198
View File
@@ -1,246 +1,205 @@
name: EasyTier OHOS
name: ohos
on:
push:
branches: ["develop", "main", "releases/**"]
branches: [develop, main, "releases/**", "ohos/**"]
tags:
- 'v*'
- '!*-pre'
- "v*"
- "!*-pre"
pull_request:
branches: ["develop", "main"]
branches: [develop, main, "ohos/**"]
types: [opened, synchronize, reopened, ready_for_review]
workflow_dispatch:
inputs:
publish:
description: Publish this non-main branch and dispatch downstream builds
required: false
default: false
type: boolean
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: read
env:
CARGO_TERM_COLOR: always
defaults:
run:
# necessary for windows
shell: bash
jobs:
cargo_fmt_check:
ohos:
name: ohos
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Prepare build environment
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up Rust
uses: ./.github/actions/prepare-build
with:
target: aarch64-unknown-linux-ohos
gui: false
pnpm: false
token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
components: rustfmt
- name: Check formatting
working-directory: ./easytier-contrib/easytier-ohrs
run: cargo fmt --all -- --check
pre_job:
# continue-on-error: true # Uncomment once integration is finished
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
# Map a step output to a job output
outputs:
# do not skip push on branch starts with releases/
should_skip: ${{ steps.skip_check.outputs.should_skip == 'true' && !startsWith(github.ref_name, 'releases/') }}
steps:
- id: skip_check
uses: fkirc/skip-duplicate-actions@v5
with:
# All of these options are optional, so you can remove them if you are happy with the defaults
concurrent_skipping: "same_content_newer"
skip_after_successful_duplicate: "true"
cancel_others: "true"
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-contrib/easytier-ohrs/**", ".github/workflows/ohos.yml", ".github/actions/**"]'
build-ohos:
runs-on: ubuntu-latest
needs: pre_job
env:
OHPM_PUBLISH_CODE: ${{ secrets.OHPM_PUBLISH_CODE }}
if: needs.pre_job.outputs.should_skip != 'true'
steps:
- uses: actions/checkout@v5
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -qq \
build-essential \
wget \
unzip \
git \
pkg-config curl libgl1-mesa-dev expect
- name: Resolve easytier version
run: |
set -e
UPSTREAM_REPO="https://github.com/EasyTier/EasyTier.git"
git remote add upstream "$UPSTREAM_REPO" 2>/dev/null || true
git fetch --unshallow upstream main || git fetch upstream main
git fetch --tags upstream --force
# 读取 cargo 版本
CARGO_VERSION=$(cargo metadata --format-version 1 --no-deps --manifest-path easytier/Cargo.toml \
| jq -r '.packages[0].version')
# 获取 upstream/main 最新 tag
LAST_TAG=$(git describe --tags --abbrev=0 upstream/main 2>/dev/null || echo "")
LAST_TAG_VERSION="${LAST_TAG#v}"
# 语义版本比较
version_gt() {
[ "$(printf '%s\n' "$1" "$2" | sort -V | tail -n1)" = "$1" ] && [ "$1" != "$2" ]
}
if [ -z "$LAST_TAG_VERSION" ]; then
BASE_VERSION="$CARGO_VERSION"
DIFF_COUNT=$(git rev-list --count upstream/main)
elif version_gt "$CARGO_VERSION" "$LAST_TAG_VERSION"; then
BASE_VERSION="$CARGO_VERSION"
DIFF_COUNT=0
else
BASE_VERSION="$LAST_TAG_VERSION"
DIFF_COUNT=$(git rev-list --count "${LAST_TAG}..upstream/main")
fi
COMMIT_HASH=$(git rev-parse --short upstream/main)
EASYTIER_VERSION="${BASE_VERSION}-${DIFF_COUNT}-${COMMIT_HASH}"
echo "EASYTIER_VERSION=$EASYTIER_VERSION"
echo "EASYTIER_VERSION=$EASYTIER_VERSION" >> $GITHUB_ENV
cd ./easytier-contrib/easytier-ohrs/package
jq --arg v "$EASYTIER_VERSION" '.version = $v' oh-package.json5 > oh-package.tmp.json5
mv oh-package.tmp.json5 oh-package.json5
- name: Generate CHANGELOG.md for current commit
working-directory: ./easytier-contrib/easytier-ohrs/package
run: |
{
echo "## easytier-ohrs ${EASYTIER_VERSION}"
echo
git log -1 --pretty=format:"- %s"
echo
} > CHANGELOG.md
- name: Setup HarmonyOS CLI tools
- name: Set up HarmonyOS
uses: ErBWs/setup-ohos@v1
- name: Download and Extract Custom SDK
run: |
wget https://github.com/FrankHan052176/Easytier-OHOS-sdk/releases/download/v1/ohos-sdk.zip -O /tmp/ohos-sdk.zip
sudo unzip -o /tmp/ohos-sdk.zip -d /tmp/custom-sdk
sudo cp -rf /tmp/custom-sdk/linux/native/* $OHOS_NDK_HOME/native
echo "Custom SDK files deployed to $OHOS_NDK_HOME/native"
ls -a $OHOS_NDK_HOME/native
- name: Setup build environment
run: |
echo "TARGET_ARCH=aarch64-linux-ohos" >> $GITHUB_ENV
rustup install stable
rustup default stable
rustup target add aarch64-unknown-linux-ohos
- uses: taiki-e/install-action@v2
- name: Install ohrs
uses: taiki-e/install-action@v2
with:
tool: ohrs
- name: Create clang wrapper script
- name: Build HAR
id: package
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
sudo mkdir -p $OHOS_NDK_HOME/native/llvm
sudo tee $OHOS_NDK_HOME/native/llvm/aarch64-unknown-linux-ohos-clang.sh > /dev/null <<'EOF'
set -euo pipefail
sudo apt-get install -qqy \
pkg-config curl libgl1-mesa-dev expect llvm clang lldb lld
rustup component add rustfmt
cargo fmt --all --manifest-path \
easytier-contrib/easytier-ohrs/Cargo.toml -- --check
cargo_version=$(cargo metadata --format-version 1 --no-deps \
--manifest-path easytier/Cargo.toml | jq -r '.packages[0].version')
last_tag=$(git describe --tags --abbrev=0 HEAD 2>/dev/null || true)
if [ -n "$last_tag" ]; then
base_version=$(printf '%s\n' "$cargo_version" "${last_tag#v}" \
| sort -V | tail -n 1)
commit_count=$(git rev-list --count "$last_tag..HEAD")
else
base_version=$cargo_version
commit_count=0
fi
source_branch=${GITHUB_HEAD_REF:-}
if [ -z "$source_branch" ]; then
if [ "$GITHUB_REF_TYPE" = branch ]; then
source_branch=$GITHUB_REF_NAME
else
source_branch=${DEFAULT_BRANCH:-main}
fi
fi
branch_id=$(printf '%s' "$source_branch" \
| tr '[:upper:]' '[:lower:]' \
| sed -E 's/[^a-z0-9-]+/-/g; s/^-+//; s/-+$//' \
| cut -c1-64)
branch_id=${branch_id:-main}
package_name=easytier-ohrs
package_version="${base_version}-${branch_id}-${commit_count}-${GITHUB_RUN_NUMBER}-${GITHUB_RUN_ATTEMPT}-g$(git rev-parse --short=8 HEAD)"
echo "name=$package_name" >> "$GITHUB_OUTPUT"
echo "EASYTIER_PACKAGE_NAME=$package_name" >> "$GITHUB_ENV"
echo "EASYTIER_VERSION=$package_version" >> "$GITHUB_ENV"
package_dir=easytier-contrib/easytier-ohrs/package
jq --arg name "$package_name" --arg version "$package_version" \
'.name = $name | .version = $version' \
"$package_dir/oh-package.json5" > "$package_dir/oh-package.tmp.json5"
mv "$package_dir/oh-package.tmp.json5" "$package_dir/oh-package.json5"
{
echo "## $package_name $package_version"
echo
echo "- Core version: $base_version"
echo "- Core commit: $GITHUB_SHA"
git log -1 --pretty=format:'- %s'
echo
} > "$package_dir/CHANGELOG.md"
sudo mkdir -p "$OHOS_NDK_HOME/native/llvm"
sudo tee "$OHOS_NDK_HOME/native/llvm/aarch64-unknown-linux-ohos-clang.sh" >/dev/null <<'EOF'
#!/bin/sh
exec $OHOS_NDK_HOME/native/llvm/bin/clang \
exec "$OHOS_NDK_HOME/native/llvm/bin/clang" \
-target aarch64-linux-ohos \
--sysroot=$OHOS_NDK_HOME/native/sysroot \
-D__MUSL__ \
"$@"
--sysroot="$OHOS_NDK_HOME/native/sysroot" \
-D__MUSL__ "$@"
EOF
sudo chmod +x $OHOS_NDK_HOME/native/llvm/aarch64-unknown-linux-ohos-clang.sh
sudo chmod +x \
"$OHOS_NDK_HOME/native/llvm/aarch64-unknown-linux-ohos-clang.sh"
- name: Build latest Har
working-directory: ./easytier-contrib/easytier-ohrs
run: |
sudo apt-get install -y llvm clang lldb lld
sudo apt-get install -y protobuf-compiler
cd easytier-contrib/easytier-ohrs
source env.sh
ohrs doctor
ohrs build --release --arch aarch
ohrs artifact
mv package.har easytier-ohrs.har
mv package.har "$package_name.har"
- name: Build Release Package
if: startsWith(github.ref, 'refs/tags/')
working-directory: ./easytier-contrib/easytier-ohrs
run: |
echo "🎉 Official Release detected. Building easytier-release..."
TAG_NAME="${{ github.ref_name }}"
TAG_VERSION="${TAG_NAME#v}"
echo "Release Version: $TAG_VERSION"
cd package
jq --arg v "$TAG_VERSION" '.name = "easytier-release" | .version = $v' oh-package.json5 > oh-package.tmp.json5 && mv oh-package.tmp.json5 oh-package.json5
cd ..
ohrs build --release --arch aarch
cd dist/arm64-v8a
mv libeasytier_ohrs.so libeasytier_release.so
cd ../..
ohrs artifact
mv package.har easytier-release.har
- name: Upload artifact
- name: Upload HAR
uses: actions/upload-artifact@v5
with:
name: easytier-ohos
path: |
./easytier-contrib/easytier-ohrs/easytier-ohrs.har
name: ${{ steps.package.outputs.name }}
path: easytier-contrib/easytier-ohrs/${{ steps.package.outputs.name }}.har
retention-days: 5
if-no-files-found: error
- name: Publish To Center Ohpm
working-directory: ./easytier-contrib/easytier-ohrs
- name: Publish and dispatch
if: >-
(github.event_name == 'push' &&
github.ref_type == 'branch' &&
github.ref_name == 'main' &&
github.event.forced != true) ||
(github.event_name == 'workflow_dispatch' &&
github.ref_type == 'branch' &&
(github.ref_name == 'main' || inputs.publish))
working-directory: easytier-contrib/easytier-ohrs
env:
OHPM_PRIVATE_KEY: ${{ secrets.OHPM_PRIVATE_KEY }}
OHPM_KEY_PASSPHRASE: ${{ secrets.OHPM_KEY_PASSPHRASE }}
if: ${{ env.OHPM_PUBLISH_CODE != '' && github.event_name == 'push' }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CODEARTS_PRIVATE_OHPM: ${{ secrets.CODEARTS_PRIVATE_OHPM }}
DOWNSTREAM_DISPATCH_TOKEN: ${{ secrets.DOWNSTREAM_DISPATCH_TOKEN }}
run: |
ohpm config set publish_id "$OHPM_PUBLISH_CODE"
ohpm config set publish_registry https://ohpm.openharmony.cn/ohpm
TMP_DIR=$(mktemp -d)
PRIVATE_KEY_FILE="$TMP_DIR/private_key"
printf '%s' "$OHPM_PRIVATE_KEY" > "$PRIVATE_KEY_FILE"
chmod 600 "$PRIVATE_KEY_FILE"
ohpm config set key_path $PRIVATE_KEY_FILE
unzip ohpm_crypto.zip -d /home/runner/work/
ohpm config set crypto_path /home/runner/work/ohpm_crypto
chmod 755 /home/runner/work/ohpm_crypto/*
PASSPHRASE="$(printf '%s' "$OHPM_KEY_PASSPHRASE" | tr -d '\r\n')"
ohpm config set key_passphrase "$PASSPHRASE"
ohpm publish easytier-ohrs.har
- name: Publish To Private Ohpm
working-directory: ./easytier-contrib/easytier-ohrs
if: ${{ env.OHPM_PUBLISH_CODE != '' && github.event_name == 'push' }}
run: |
printf '%s' "${{ secrets.CODEARTS_PRIVATE_OHPM }}" > ~/.ohpm/.ohpmrc
ohpm config set strict_ssl false
ohpm publish easytier-ohrs.har
if [ -f "easytier-release.har" ]; then
echo "🚀 Publishing Release package..."
ohpm publish easytier-release.har
set -euo pipefail
if [ "$GITHUB_EVENT_NAME" = push ]; then
pull_requests=$(gh api \
-H "Accept: application/vnd.github+json" \
"/repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls")
if ! jq -e \
--arg repository "$GITHUB_REPOSITORY" \
--arg branch "$GITHUB_REF_NAME" \
--arg sha "$GITHUB_SHA" \
'any(.[];
.merged_at != null and
.base.repo.full_name == $repository and
.base.ref == $branch and
.merge_commit_sha == $sha)' \
<<< "$pull_requests" >/dev/null; then
echo "Direct push: HAR built without publishing."
exit 0
fi
fi
curl --header "Content-Type: application/json" --request POST --data "{}" ${{ secrets.CODEARTS_WEBHOOKS }}
mkdir -p "$HOME/.ohpm"
umask 077
printf '%s' "$CODEARTS_PRIVATE_OHPM" > "$HOME/.ohpm/.ohpmrc"
trap 'rm -f "$HOME/.ohpm/.ohpmrc"' EXIT
ohpm publish "$EASYTIER_PACKAGE_NAME.har"
payload=$(jq -nc \
--arg repository "$GITHUB_REPOSITORY" \
--arg ref "refs/heads/$GITHUB_REF_NAME" \
--arg package "$EASYTIER_PACKAGE_NAME" \
'{
event_type: "core-har-published",
client_payload: {
core_repository: $repository,
core_ref: $ref,
package_name: $package
}
}')
for repository in \
FrankHan052176/EasyTier-ArkTS \
FrankHan052176/easytier-pro-app; do
curl --fail-with-body --silent --show-error \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $DOWNSTREAM_DISPATCH_TOKEN" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"$GITHUB_API_URL/repos/$repository/dispatches" \
--data "$payload"
done
+1 -1
View File
@@ -18,7 +18,7 @@ on:
version:
description: 'Version for this release'
type: string
default: 'v2.6.3'
default: 'v2.6.4'
required: true
make_latest:
description: 'Mark this release as latest'
+11 -3
View File
@@ -34,7 +34,7 @@ jobs:
# All of these options are optional, so you can remove them if you are happy with the defaults
concurrent_skipping: 'never'
skip_after_successful_duplicate: 'true'
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", ".github/workflows/test.yml", ".github/actions/**"]'
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-core/**", "easytier-proto/**", "easytier-web/**", "easytier-gui/src-tauri/**", "easytier-contrib/**", ".github/workflows/test.yml", ".github/actions/**"]'
check:
name: Run linters & check
@@ -98,7 +98,9 @@ jobs:
- uses: taiki-e/install-action@nextest
- name: Archive test
run: cargo nextest archive --archive-file tests.tar.zst --package easytier --features full
run: >-
cargo nextest archive --archive-file tests.tar.zst
--package easytier --package easytier-core --features full
- uses: actions/upload-artifact@v5
with:
@@ -136,6 +138,11 @@ jobs:
- name: Setup system for test
run: |
sudo modprobe br_netfilter
sudo modprobe tun
if [ ! -e /dev/net/tun ]; then
sudo mkdir -p /dev/net
sudo mknod /dev/net/tun c 10 200
fi
sudo sysctl net.bridge.bridge-nf-call-iptables=0
sudo sysctl net.bridge.bridge-nf-call-ip6tables=0
sudo sysctl net.ipv6.conf.lo.disable_ipv6=0
@@ -151,7 +158,8 @@ jobs:
- name: Run tests
run: |
sudo prlimit --pid $$ --nofile=1048576:1048576
sudo -E env "PATH=$PATH" cargo nextest run --archive-file tests.tar.zst ${{ matrix.opts }}
sudo -E env "PATH=$PATH" EASYTIER_LINUX_BPF_INTEGRATION=required \
cargo nextest run --archive-file tests.tar.zst ${{ matrix.opts }}
test:
runs-on: ubuntu-latest
+6
View File
@@ -34,6 +34,9 @@ easytier-panic.log
# web
node_modules
easytier-web/frontend-lib/src/generated/
easytier-web/config-generator/dist/
easytier-web/config-generator/src/generated/
.vite
@@ -43,3 +46,6 @@ easytier-gui/src-tauri/*.sys
.direnv
.flake-profile
# contrib
go.sum
+30
View File
@@ -0,0 +1,30 @@
# EasyTier Domain Context
## Module layers
`easytier-core` layers dependencies from `foundation` upward through the
portable networking domains. `foundation` contains infrastructure Modules
that have no dependency on a networking domain and may be used by any higher
layer.
## Operation broker
An operation broker owns the lifecycle of asynchronous work submitted by an
external caller to core. It allocates opaque operation IDs, arbitrates
completion, cancellation, and disposal, retains terminal outcomes, and
publishes a batch-drainable completion queue.
The broker does not interpret operation kinds, outcomes, resources, wire
formats, or domain errors. Each domain Module owns those semantics and composes
the broker under the same lock as any state that must change atomically with an
operation transition.
Host capability operations use a separate seam. They turn Host readiness into
Rust task wakeups and do not share the caller-to-core broker state machine.
## Compact compatibility Host
A compact compatibility Host retains accepted values in the authoritative TOML
model for management readback, while the shared host-aware normalization path
omits capabilities that the compact runtime cannot execute. Omitted settings
are silent no-ops and must not be advertised as live network capabilities.
+12 -1
View File
@@ -113,6 +113,17 @@ cargo build --release --target x86_64-pc-windows-msvc # Windows x86_64
Build artifacts: `target/[target-triple]/release/`
### Building the WASI core
```bash
script/build-wasi-core.sh
```
This builds the `easytier-core` Go-host profile for `wasm32-wasip1`, then
optimizes it with the pinned official Binaryen release. Binaryen is downloaded
once into `target/binaryen/` and verified by SHA-256; set `WASM_OPT` to use an
existing matching binary.
### Building GUI
```bash
@@ -222,4 +233,4 @@ Feel free to:
- Join our community discussions
- Reach out to maintainers
Thank you for contributing to EasyTier!
Thank you for contributing to EasyTier!
Generated
+443 -321
View File
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -1,10 +1,12 @@
[workspace]
resolver = "2"
members = [
"easytier-core",
"easytier-proto",
"easytier",
"easytier-gui/src-tauri",
"easytier-rpc-build",
"easytier-web",
"easytier-contrib/easytier-mini",
"easytier-contrib/easytier-ffi",
"easytier-contrib/easytier-uptime",
"easytier-contrib/easytier-android-jni",
@@ -28,3 +30,8 @@ lto = true
codegen-units = 1
opt-level = 3
strip = true
[profile.mini]
inherits = "release"
opt-level = "z"
strip = "symbols"
+529
View File
@@ -0,0 +1,529 @@
# EasyTier Core Architecture
## Status and scope
This document describes the current architecture after the portable-core
refactor. It is the source of truth for ownership, dependency direction,
feature boundaries, and validation. It intentionally records the resulting
design rather than the migration history.
The refactor has three principal crate roles:
- `easytier-core` owns portable EasyTier configuration, protocol state,
routing, peer state, connectivity orchestration, packet processing, and
instance lifecycle.
- `easytier` is the native composition root. It owns operating-system
resources, native protocol engines, process integration, CLI and native
presentation.
- `easytier-proto` owns generated protobuf and RPC types, descriptor data, and
the feature slices needed by core and presentation users.
`easytier-core` is designed to compile without direct operating-system network
access. It supports native hosts through Rust traits and has a target-only WASI
adapter and ABI implementation under `easytier-core/src/wasi`.
This architecture does not require compatibility with old internal module
paths. Wire compatibility, configuration compatibility, management semantics,
and externally used application behaviour remain compatibility requirements.
## Architectural vocabulary
The following terms have specific meanings in this document:
- **Module**: an interface and the implementation hidden behind it.
- **Host**: the process or runtime embedding core and owning platform
resources.
- **Host capability**: an operation core may request but must not implement
with direct OS calls.
- **Adapter**: a concrete implementation of a Host capability or protocol
extension.
- **Composition root**: code that creates core configuration, Host Adapters,
instances, and process-level services.
- **Runtime configuration**: the authoritative normalized state used after an
instance starts.
- **Packet plane**: portable packet classification, routing, transformation,
proxy/NAT state, and forwarding decisions.
New abstractions should pass a deletion test: deleting a useful deep Module
should force non-trivial policy or lifecycle logic to reappear in multiple
callers. A pass-through wrapper with no independent invariant is not an
architectural boundary.
## Crate dependency direction
The principal dependency direction is:
```text
easytier-proto <- easytier-core <- easytier
```
Presentation crates and platform integrations consume these crates. Portable
policy must not move outward merely because one current consumer is native.
Conversely, core must not absorb an OS mechanism or a protocol engine whose
dependencies cannot satisfy the core target contract.
### `easytier-proto`
The protobuf crate is split by public Cargo features:
- `core` provides the common wire messages, peer RPC messages, generated RPC
runtime, and descriptor bytes needed by core.
- `api` adds management API messages.
- protocol-specific features add only their generated message modules.
- `json-rpc` enables the well-known protobuf JSON types used by the management
plane.
- `full` is the compatibility aggregate used by complete products.
The core crate depends on `easytier-proto` with default features disabled and
enables only `core`, adding API or JSON-RPC types through its own management
features.
The main core/native path has no `prost-reflect` dependency. OSPF route
reflection uses the focused wire editor in
`peers/route/route_peer_wire.rs`. It retains the original encoded
`RoutePeerInfo`, replaces only the fields credential filtering is allowed to
change, and leaves all other top-level and nested fields intact. This is
required so unknown fields survive mixed-version, multi-hop propagation.
Generated Rust types remain responsible for normal message construction and
validation.
Descriptor sets are still generated and embedded by `easytier-proto`; removing
runtime reflection did not remove descriptor data used by configuration and
RPC tooling. The OHOS integration has its own schema service and dependency
policy and is outside this replacement.
### `easytier-core`
Core owns portable behaviour and exposes capability seams. Its normal
dependencies use Tokio runtime, time, synchronization, and I/O traits without
requiring the full Tokio feature set.
Core may depend on optional portable engines when their owning feature is
enabled. It does not create real native TCP/UDP sockets, alter routes, open a
TUN device, enter a network namespace, configure system DNS, manage a service,
or invoke UPnP/NAT-PMP directly.
### `easytier`
The native crate owns:
- process startup, shutdown, signals, service management, and allocators;
- filesystem configuration input and persistence;
- real TCP/UDP, DNS, TUN, raw-socket, route, interface, namespace, and socket
option operations;
- UPnP and NAT-PMP operations;
- Unix and FakeTCP resources;
- WebSocket/WSS, QUIC, WireGuard, and KCP concrete engines;
- native Magic DNS serving and system DNS integration;
- CLI, web, GUI, FFI, and native management presentation.
Native code may translate values and assemble Adapters. It must not maintain a
second peer graph, reproduce core routing or hole-punch policy, or invent an
alternative instance lifecycle.
## Internal core layers
The physical module layout follows this downward order:
```text
foundation
<- config / packet
<- socket
<- host
<- tunnel
<- listener / connectivity
<- peers / rpc
<- gateway
<- instance
<- management
```
`process_runtime` is a process- or module-scoped owner shared by instances.
`wasi` is target integration and is compiled only for tests or the WASI target;
it is not an additional portable domain layer.
### Foundation
`foundation/` contains task supervision, the time facade, rate limiting,
statistics primitives, and the domain-neutral external operation broker. The
broker owns asynchronous operation lifecycle and completion storage while the
calling domain owns operation kinds, outcomes, resources, and errors.
Foundation must not depend on a domain layer.
### Configuration and packets
`config/` owns:
- the complete `TomlConfig` model;
- parsing, serialization, and validation;
- OS-independent defaults;
- peer, encryption, gateway, and API input models;
- normalized runtime snapshots and the live runtime configuration store.
The Host supplies platform facts through `CoreInstanceHostConfig`. Core applies
the policy that combines those facts with TOML input. This is especially
important for a WASI build: the compile-time guest target cannot be used as a
proxy for the Host operating system.
`packet/` owns EasyTier packet structures, compression, STUN and hole-punch
wire codecs. It does not own socket I/O or connection policy.
### Socket and Host seams
`socket/` contains transport-neutral primitives:
- `SocketContext`, including IP-family policy, optional socket mark, and an
opaque network-namespace token;
- virtual TCP socket, listener, and factory traits;
- virtual UDP socket and factory traits;
- UDP session multiplexing, classification, and lifecycle;
- in-process Ring sockets.
`host/` is the single home of Host capability seams:
- DNS and DNS record resolution;
- connector environment observations;
- packet ingress and egress;
- Host socket operation bridges and handle-based TCP/UDP/listener adapters.
Core owns scheduling, backpressure, cancellation, UDP session state, and
protocol state even when each actual operation crosses a Host Adapter. A Host
Adapter owns the real resource and performs the OS operation.
The native `NativeHostRuntime` is process-wide and does not retain an instance
`GlobalCtx`, namespace guard, socket mark, or connectivity state. Differences
between instances travel in each request's `SocketContext`. A narrow
instance-host projection may expose listener and interface facts, but it does
not become another socket factory.
### Tunnel and listener
A socket is a raw communication endpoint. A Tunnel is an EasyTier connection
created by adding framing, metadata, handshakes, and protocol lifecycle.
Core owns:
- raw TCP framing and upgrade;
- UDP tunnel/session framing and classification;
- Ring Tunnel identity and registry state;
- encryption and secure-datagram policy that is portable;
- client/server protocol selection interfaces;
- listener planning, optional/required listener policy, retry, accept
scheduling, running-listener registry, and orderly shutdown.
Native protocol Adapters own WebSocket/WSS, QUIC, WireGuard, and KCP engines.
Unix and FakeTCP are socket resources that feed a core protocol upgrader; they
are not independent owners of EasyTier peer state.
Each protocol registration must provide a coherent client/server Adapter.
Unavailable configured transports must be rejected during validation or
protocol selection in the standard runtime, rather than silently falling back
to another transport. A compact compatibility Host may instead retain the
desired value for management readback and omit it from normalized runtime
state; it must not advertise or partially activate the unavailable transport.
### Connectivity
`connectivity/` owns:
- manual connection and endpoint discovery policy;
- direct candidate selection;
- retry, backoff, blacklists, and listener reuse;
- STUN requests, responses, probing, NAT inference, and published endpoint
state;
- TCP and UDP hole-punch state machines;
- UDP port-mapping policy and lease lifecycle;
- conversion of successful sockets into protocol-upgrade requests.
The Host owns DNS execution, socket syscalls, interface enumeration, bind
device/mark/namespace operations, and concrete UPnP/NAT-PMP calls. STUN-only
hole punching remains available when the Host does not supply a port-mapping
Adapter.
Some connectivity files intentionally implement peer-facing adapter traits for
`PeerManagerCore`. These are localized integration edges between adjacent
domains, not permission for lower socket or Host layers to depend on peers.
### Peers and RPC
`peers/` is the authoritative owner of:
- admission and connection sessions;
- peer maps and connection lifecycle;
- ACL and whitelist decisions;
- OSPF route calculation and graph algorithms;
- peer and credential RPC registration;
- foreign-network admission, identity, relay, and lifecycle;
- peer-center state and public IPv6 policy;
- traffic metrics and peer snapshots.
Submodules progress from kernel types and utilities, through ACL/context,
connection state, route state, manager services, and finally foreign-network
and peer-center composition. Callers consume the public surface declared by
the domain rather than reaching into a parallel native peer owner.
`rpc/` owns the peer-flavoured RPC transport, packet fragmentation, client and
server lifecycle, handler registry, and standalone listener/client lifecycle.
Generated service descriptors and message types remain in `easytier-proto`.
### Gateway
`gateway/` owns portable packet-plane features:
- proxy CIDR state and monitoring policy;
- packet parsing, reassembly, NAT/proxy state, and TCP/UDP/ICMP decisions;
- the smoltcp-backed portable dataplane selected by its feature;
- SOCKS5 framing, authentication, association, routing, and session state;
- wrapped-transport planning and session state used by KCP and QUIC Adapters;
- DHCP allocation policy;
- Magic DNS route and response policy;
- VPN portal client/session policy;
- UDP broadcast classification and rewrite policy.
TUN, raw sockets, transparent-destination lookup, concrete protocol engines,
native DNS servers, namespace operations, and route application stay in native
Adapters.
Optional gateway capabilities are selected by cohesive Modules. Disabled
implementations retain stable lifecycle calls and report unsupported
configuration in the standard runtime. A compact compatibility Host may
silently normalize those settings to no-ops while preserving the desired TOML
model; disabled implementations do not duplicate portable policy.
The instance-scoped `DataPlaneSession` composes the foundation operation broker
under the same session lock as its resource and quota state. The broker owns
generic completion, cancellation, free, drain, and take transitions. The data
plane retains TCP/UDP resource ownership, operation metadata, route deadlines,
and error semantics.
The proposed restructuring of the smoltcp data plane, SOCKS5 and port-forward
Adapters, portable KCP engine, event-driven FFI/WASI completion model, and Go
Host integration is tracked in
[`data-plane-runtime-plan.md`](data-plane-runtime-plan.md). That document is a
future implementation plan; this document remains the source of truth for the
currently implemented architecture until the plan is completed.
### Instance and management
`CoreInstance::new(CoreInstanceConfig, CoreHostAdapters)` is the sole direct
construction path for a normalized instance. `CoreInstance::from_toml` uses
the same normalization and construction path. Core constructs the peer graph,
runtime store, STUN collector, connectivity managers, listener runtime, packet
plane, gateway runtimes, and lifecycle owners.
A core instance:
- owns all mutable portable state for one network;
- is one-shot after `stop`;
- exposes one complete `start` and one `stop` lifecycle interface;
- starts Modules in a fixed serial composition order without cross-Module
started flags or staged activation;
- installs initial ACL, proxy CIDR, and manual-peer inputs before startup;
- serializes lifecycle operations with one instance-level operation lock;
- owns cooperative cancellation and component shutdown order;
- exposes `CorePacketPlane` as the narrow packet/route projection used by Host
dataplane Adapters;
- treats its normalized runtime store as authoritative after construction.
`CoreHostAdapters` contains the required Host, DNS, packet sink, and
`CoreProcessRuntime`, plus optional protocol and platform capabilities. The
bundle carries capabilities, not preconstructed portable managers.
Each Module owns partial-start cleanup for its internal resources.
`CoreInstance` has one outer cancellation and recovery path for the complete
serial startup. `Running` therefore means the Host runtime and every enabled
portable Module have started successfully; there is no separate post-Host
activation state. Host packet tasks stop before PeerManager resources are
cleared.
`InstanceManager<F>` is the canonical UUID-indexed instance collection for one
Host composition. Its `InstanceFactory` constructs one complete record before
the manager performs an atomic uniqueness check. The manager owns collection
membership; it does not own startup order, persistence, daemon policy, cached
errors, ABI handles, or RPC projections.
`management/` consumes the canonical manager and instances. It owns:
- stable UUID/name selection;
- read-only instance and peer management RPC;
- full process mutation and configuration transactions when enabled;
- persistence and logger-control capability interfaces;
- management listener/client lifecycle and JSON-RPC presentation.
There is one process-level management entry. Instances and the manager do not
depend on management response projections.
## Process-scoped state
`CoreProcessRuntime` owns portable resources shared across instances in one
process or instantiated module:
- the Ring Tunnel registry and namespace;
- a reference-counted protected TCP-port registry.
The composition root creates and shares one runtime. Management listener ports
are protected before bind and held by leases after the concrete port is known.
Native and target adapters supply bound resources but do not implement a
second protected-port registry.
Process-global capability objects may contain stateless or shared platform
mechanisms. They must not contain instance-specific peer, route,
configuration, or connectivity state.
## Runtime configuration authority
`TomlConfig` is the authoritative desired configuration used for management
readback and patch transactions. Compact Hosts keep unsupported accepted values
there so controllers observe the configuration they submitted.
The separately typed, normalized core runtime store is authoritative for live
behavior:
- peer feature flags and routing policy;
- listeners and initial peers;
- ACL and whitelist inputs;
- manual and VPN portal CIDRs;
- gateway and connectivity settings;
- runtime configuration patches.
Host persistence is an effect following a successful core transaction. A Host
Adapter must not call back into an instance to obtain a hidden configuration
snapshot while core is applying an operation.
Non-serializable resources such as TUN descriptors, packet sinks, execution
domains, and native protocol engines are construction context, not TOML
fields.
## Logging
The main native runtime uses a small logger implemented in
`easytier/src/common/log`:
- `log` records and `tracing` events share console and file sinks;
- timestamps, compact formatting, optional terminal colours, `NO_COLOR`, and
basic `RUST_LOG` target/level filters are implemented directly;
- file rotation uses the existing EasyTier rolling appender;
- management RPC can reload the file level;
- an atomic maximum-level gate rejects disabled events before target matching
or file-filter locking;
- concurrent file-level reload serializes the filter and atomic-level update.
File logging and no-file logging are separate selected backends. The default
tracing backend records events and deliberately ignores span trees. The
optional `tracing` feature selects the tokio-console subscriber integration;
only that diagnostic profile pulls the main crate's `tracing-subscriber` and
`console-subscriber` dependencies.
Contrib applications and platform integrations may have independent logging
requirements and are not implicitly wired to the native process logger.
## Feature model
Features represent coherent capabilities, not arbitrary source fragments.
Important core feature relationships are:
- `management-rpc` enables generated management API types and read-only
management services.
- `management` adds configuration writes, full management composition, rich
errors, and JSON-RPC.
- `proxy-packet` enables portable packet parsing/proxy machinery and the
required smoltcp packet features.
- `proxy-smoltcp-stack` adds the async TCP/UDP smoltcp stack.
- `dns-resolver` is the shared Hickory resolver leaf used by endpoint
discovery and Magic DNS without coupling either capability to the other.
- `endpoint-discovery` adds HTTPS endpoint discovery dependencies.
- `magic-dns` enables its DNS server, management wire messages, and portable
packet-query integration.
- `tcp-hole-punch` enables the TCP hole-punch runtime.
- `dhcp-ipv4`, `public-ipv6-provider`, `vpn-portal`,
`wrapped-transport`, and `proxy-cidr-monitor` are independent gateway or
platform-policy leaves.
- `extended-services` is the compatibility aggregate for those leaves.
- encryption and compression engines remain independently selectable.
The native crate maps product features to the core and protocol features it
actually consumes. A protocol feature must not accidentally enable unrelated
gateway or management capabilities.
Production feature and platform selection belongs at Module or Adapter
boundaries rather than inside shared implementations. The logger demonstrates
the intended pattern: file and tracing variants are complete backend modules
with one stable interface, so shared event processing contains no feature
branches.
## Module boundaries
The dependency directions in this document define the intended module
boundaries. Changes that require a new upward edge must first define a stable
lower-layer interface or explicitly revise this architecture.
Modules are `pub(crate)` by default. Each domain's `mod.rs` declares its
outward surface. Public visibility is used for real cross-crate Host,
configuration, management, packet-plane, or test-support interfaces.
## Architectural invariants
1. Portable EasyTier policy has one owner in `easytier-core`.
2. Core does not perform real OS socket, DNS, TUN, route, filesystem
configuration, process, or service-manager operations.
3. Host-OS policy is runtime input; a WASI compile target is not Host policy.
4. Every real socket and DNS operation crosses a Host capability seam.
5. Core owns socket scheduling, backpressure, protocol state, and cancellation.
6. Dial, accept, and hole-punch paths produce sockets before protocol upgrade.
7. Peer admission consumes upgraded transports and does not create OS
resources.
8. Each instance owns its mutable peer, route, connectivity, gateway, and
runtime configuration state.
9. One Host composition has one canonical UUID-to-instance manager.
10. Process-level runtimes do not capture instance state.
11. `CoreInstance::new` is the sole normalized direct construction entry.
12. The manager owns membership, not lifecycle or presentation.
13. Management consumes the manager; the manager does not return management
projections.
14. Unknown protobuf fields in reflected route information survive forwarding
and credential filtering.
15. Feature selection is localized at cohesive Module/Adapter boundaries.
16. The standard runtime rejects unsupported configured capabilities. Compact
compatibility Hosts may preserve them as runtime no-ops, but never change
wire protocol, advertise them, or silently fall back to an unsafe mode.
## Validation
Changes to these boundaries should run, at minimum:
```text
cargo fmt --all -- --check
cargo check -p easytier-core -p easytier-proto -p easytier --features full
cargo test -p easytier-core --lib
```
Feature work should add focused checks for the changed no-default, isolated,
default, full, and cross-target profiles. Socket, TUN, namespace, protocol
engine, and multi-node changes require the relevant Docker integration tests.
WASI ABI or Adapter changes require a `wasm32-wasip1` build and target-side
tests. These compiler-resolved profiles are the authority for feature and
target boundaries.
CI path filters include `easytier-core`, `easytier-proto`, native, web, GUI
Tauri, and contrib. The archived Rust test suite contains both `easytier` and
`easytier-core`.
## Known limitations and debt
- Some production feature and platform gates still select fields or statements
inside shared implementations. New code should prefer complete Module or
Adapter variants, and existing cases should move only when their owning
Module is changed.
- Connectivity retains localized Adapter implementations that name
`PeerManagerCore`; further decoupling requires an interface extraction, not
a visibility-only move.
- Native Linux namespace guards exist in paths that can cross async suspension.
Because `setns` is thread-local, those operations should eventually be kept
on one non-migrating execution context.
- QUIC session retirement after failed or exhausted accepted sessions remains
separate native-engine correctness work; it must preserve multiple
connections sharing one QUIC endpoint/session.
These limitations are not reasons to add fallback owners or parallel state.
Fixes should preserve the ownership rules above and address the responsible
Module directly.
File diff suppressed because it is too large Load Diff
+65
View File
@@ -0,0 +1,65 @@
# HarmonyOS HAR delivery
The `ohos` workflow builds the Core HAR on pushes, pull requests, tags, and
manual runs. Every successful run retains a short-lived HAR artifact, while
publication to the private OHPM registry is deliberately restricted:
- A push to `main` publishes only when the pushed SHA is the merge commit of a
pull request targeting `main` and the push is not forced.
- A manual run on `main` publishes by default.
- A manual run on another branch publishes only when its `publish` input is
enabled.
- Direct pushes, pull requests, tags, and ordinary non-main branch builds do
not publish.
## Package identity
All branches publish the same private package name, `easytier-ohrs`. The
source branch is encoded in the package version instead of the package name:
```text
<core-version>-<branch-id>-<commits-since-tag>-<run-number>-<run-attempt>-g<short-sha>
```
`branch-id` is a lowercase, OHPM-safe form of the source branch. Publishing a
new version advances the registry's `latest` version. After publication, Core
sends the `core-har-published` repository dispatch to the ArkTS and Pro
repositories. The payload contains only `core_repository`, `core_ref`, and
`package_name`.
## App install sequence
ArkTS and Pro use the same three OHPM commands:
```bash
ohpm uninstall "$CORE_HAR_PACKAGE"
ohpm install "$CORE_HAR_PACKAGE@latest" \
--registry "$CORE_HAR_REGISTRY"
ohpm install
```
The App workflow then reads the installed version from:
```text
oh_modules/<package_name>/oh-package.json5
```
The existing `oh-package-lock.json5` and `oh_modules` directory are not
manually deleted. Because the package name remains `easytier-ohrs`, downstream
source imports do not need to be rewritten.
## Secrets
Core requires:
- `CODEARTS_PRIVATE_OHPM`: publish-capable OHPM configuration.
- `DOWNSTREAM_DISPATCH_TOKEN`: permission to dispatch both App repositories.
ArkTS and Pro require:
- `CODEARTS_PRIVATE_OHPM_READ`: read-only private OHPM authentication.
- `SIGNING_REPOSITORY_TOKEN`: read access to the corresponding private signing
repository.
Signing and AppGallery Connect credentials remain downstream application
concerns and are not passed through the Core dispatch payload.
@@ -0,0 +1,176 @@
# QUIC TCP Proxy 内存对比(2026-07-27
## 结论
在相同的双节点 network namespace 环境中,当前分支相对 2.6.4:
- 空闲且未建立 TCP proxy 连接时,两端合计 USS 从 15.95 MiB
降至 11.66 MiB,下降 26.9%
- 66 条空闲 TCP proxy 连接时,两端合计 USS 从 19.45 MiB
降至 13.85 MiB,下降 28.8%
- 固定 1 Gbit/s 的单流 TCP proxy 传输中,两端平均 USS 从
20.69 MiB 降至 14.75 MiB,下降 28.7%,同步峰值从
21.50 MiB 降至 15.02 MiB
- 从 0 增长到 66 条空闲连接推算,每条连接在两个 core 上合计
增加约 33.9 KiB USS2.6.4 为 54.2 KiB,下降 37.4%
- 当前分支的匿名内存下降约 40% 至 44%,说明堆和连接缓冲区开销
确实降低。
当前分支的 RSS 比 2.6.4 高约 5% 至 11%,但这部分差异没有出现在
Anonymous 中,主要体现为非匿名或共享驻留页。PSS 在高连接数及
固定吞吐场景基本持平,USS 和 Anonymous 则显著更低。因此不能
只根据 RSS 判断发生了内存回退。
## 测试对象
| 版本 | 标识 | 二进制 |
|---|---|---|
| 当前分支 | commit `9e2ed33aeb37`,版本 `2.6.4-9e2ed33a` | `target/x86_64-unknown-linux-musl/release/easytier-core` |
| 2.6.4 | 版本 `2.6.4-8428a89d` | `/data/tickets/easytier/easytier-linux-x86_64/easytier-core` |
当前分支使用以下命令重新构建,确保被测二进制准确对应 HEAD:
```console
cargo build --release \
--target x86_64-unknown-linux-musl \
-p easytier \
--features jemalloc \
--bin easytier-core \
--bin easytier-cli
```
两个二进制均为 stripped static PIE。当前分支明确使用 musl 和
jemalloc。
## 测试拓扑
- 两个 `easytier-core` 分别运行在独立的 network namespace
- namespace 通过 Linux bridge 和 veth 连接;
- underlay 地址为 `10.251.89.10/24``10.251.89.11/24`
- EasyTier 虚拟地址为 `10.144.144.1/24`
`10.144.144.2/24`
- 两个节点之间使用 UDP listener 建立 EasyTier peer 连接;
- 源节点启用 `--enable-quic-proxy true`
- 两个节点均保留默认 QUIC input;
- TCP client 从 `10.144.144.1` 访问绑定在
`10.144.144.2` 上的 server
- `tcp_proxy_connect` 指标的 `protocol` 标签确认为 `QUIC`
- 66 条连接场景通过两端各 132 个 established TCP socket 条目
确认当前连接数。
## 采样口径
数据读取自 `/proc/<pid>/smaps_rollup`
- RSS:进程映射的全部驻留页,包含共享代码页;
- PSS:共享页按共享进程数量分摊后的驻留内存;
- USS`Private_Clean + Private_Dirty`,表示进程独占内存;
- Anonymous:匿名页,主要反映堆、栈和运行时缓冲区。
空闲场景每隔 2 秒采样一次,共 5 次,表格记录均值。固定吞吐场景
持续 20 秒,每隔 2 秒采样一次,共 8 次,同时记录均值和峰值。
所有容量单位均为 MiB。
## 空闲连接结果
以下数据均为两个 EasyTier core 的合计值:
| 当前连接数 | 当前 RSS | 2.6.4 RSS | 当前 PSS | 2.6.4 PSS | 当前 USS | 2.6.4 USS | USS 变化 | 当前 Anonymous | 2.6.4 Anonymous |
|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| 0 | 40.42 | 36.33 | 26.03 | 24.50 | 11.66 | 15.95 | -26.9% | 8.89 | 15.62 |
| 1 | 41.52 | 38.46 | 26.49 | 25.98 | 11.46 | 16.82 | -31.9% | 9.08 | 16.23 |
| 10 | 41.63 | 38.79 | 26.59 | 26.32 | 11.56 | 17.15 | -32.6% | 9.19 | 16.55 |
| 66 | 43.68 | 41.11 | 28.76 | 28.63 | 13.85 | 19.45 | -28.8% | 11.22 | 18.79 |
0 条和 1 条连接之间的小幅反向波动属于分配器回收和采样时序噪声,
不能解释为连接产生负开销。使用 0 到 66 条连接的跨度估算单位
连接成本更稳定。
### 分节点 USS
| 当前连接数 | 当前源端 | 当前目的端 | 2.6.4 源端 | 2.6.4 目的端 |
|---:|---:|---:|---:|---:|
| 0 | 5.45 | 6.21 | 8.07 | 7.88 |
| 1 | 5.43 | 6.03 | 8.68 | 8.14 |
| 10 | 5.49 | 6.07 | 8.90 | 8.25 |
| 66 | 6.60 | 7.25 | 10.19 | 9.25 |
### 单位连接增量
以 0 到 66 条连接的 USS 增量计算:
| 版本 | 两端 USS 增量 | 每连接两端合计 | 每连接单端平均 |
|---|---:|---:|---:|
| 当前分支 | 2.19 MiB | 33.9 KiB | 17.0 KiB |
| 2.6.4 | 3.50 MiB | 54.2 KiB | 27.1 KiB |
当前分支的每连接独占内存增量下降约 37.4%。
## 固定 1 Gbit/s 活跃流量
为排除两个版本最大吞吐不同造成的缓冲区差异,使用
`iperf3 -b 1G -P 1 -t 20` 将两个版本都限制为 1 Gbit/s。
两次测试均实际完成 2.33 GiB 传输,接收端报告 1000 Mbit/s。
### 平均值
| 版本 | 节点 | RSS | PSS | USS | Anonymous |
|---|---|---:|---:|---:|---:|
| 当前分支 | 源端 | 22.89 | 15.41 | 7.94 | 6.88 |
| 当前分支 | 目的端 | 21.77 | 14.29 | 6.82 | 5.27 |
| 当前分支 | 两端合计 | 44.67 | 29.70 | 14.75 | 12.14 |
| 2.6.4 | 源端 | 22.59 | 16.34 | 11.70 | 11.35 |
| 2.6.4 | 目的端 | 19.95 | 13.67 | 8.99 | 8.77 |
| 2.6.4 | 两端合计 | 42.53 | 30.01 | 20.69 | 20.13 |
### 对比
| 指标 | 当前分支 | 2.6.4 | 变化 |
|---|---:|---:|---:|
| 两端平均 RSS | 44.67 | 42.53 | +5.0% |
| 两端平均 PSS | 29.70 | 30.01 | -1.0% |
| 两端平均 USS | 14.75 | 20.69 | -28.7% |
| 两端平均 Anonymous | 12.14 | 20.13 | -39.7% |
| 两端同步峰值 USS | 15.02 | 21.50 | -30.2% |
## 分节点原始统计
下表保留各场景所有样本计算出的均值;`max_uss` 是该节点采样期间
的最大 USS。
| 版本 | 场景 | 节点 | 样本数 | mean_rss | mean_pss | mean_uss | mean_anon | max_uss |
|---|---|---|---:|---:|---:|---:|---:|---:|
| 当前 | 0 连接 | 源端 | 5 | 19.830 | 12.636 | 5.451 | 4.314 | 5.582 |
| 当前 | 0 连接 | 目的端 | 5 | 20.587 | 13.393 | 6.208 | 4.579 | 6.320 |
| 当前 | 1 连接 | 源端 | 5 | 20.463 | 12.944 | 5.432 | 4.401 | 5.465 |
| 当前 | 1 连接 | 目的端 | 5 | 21.061 | 13.541 | 6.030 | 4.682 | 6.051 |
| 当前 | 10 连接 | 源端 | 5 | 20.522 | 13.002 | 5.491 | 4.459 | 5.496 |
| 当前 | 10 连接 | 目的端 | 5 | 21.105 | 13.585 | 6.073 | 4.726 | 6.086 |
| 当前 | 66 连接 | 源端 | 5 | 21.513 | 14.050 | 6.595 | 5.498 | 6.672 |
| 当前 | 66 连接 | 目的端 | 5 | 22.169 | 14.706 | 7.251 | 5.723 | 7.375 |
| 当前 | 1 Gbit/s | 源端 | 8 | 22.893 | 15.410 | 7.936 | 6.877 | 8.188 |
| 当前 | 1 Gbit/s | 目的端 | 8 | 21.773 | 14.291 | 6.816 | 5.266 | 6.832 |
| 2.6.4 | 0 连接 | 源端 | 5 | 18.278 | 12.355 | 8.071 | 7.876 | 8.328 |
| 2.6.4 | 0 连接 | 目的端 | 5 | 18.048 | 12.144 | 7.880 | 7.747 | 8.203 |
| 2.6.4 | 1 连接 | 源端 | 5 | 19.535 | 13.279 | 8.676 | 8.262 | 8.727 |
| 2.6.4 | 1 连接 | 目的端 | 5 | 18.920 | 12.705 | 8.143 | 7.971 | 8.191 |
| 2.6.4 | 10 连接 | 源端 | 5 | 19.762 | 13.506 | 8.902 | 8.473 | 8.910 |
| 2.6.4 | 10 连接 | 目的端 | 5 | 19.027 | 12.812 | 8.250 | 8.078 | 8.297 |
| 2.6.4 | 66 连接 | 源端 | 5 | 21.069 | 14.805 | 10.194 | 9.702 | 10.320 |
| 2.6.4 | 66 连接 | 目的端 | 5 | 20.045 | 13.822 | 9.252 | 9.088 | 9.293 |
| 2.6.4 | 1 Gbit/s | 源端 | 8 | 22.588 | 16.343 | 11.697 | 11.354 | 12.258 |
| 2.6.4 | 1 Gbit/s | 目的端 | 8 | 19.946 | 13.670 | 8.993 | 8.774 | 9.277 |
## 解释和限制
1. 以固定 1 Gbit/s 场景为例,当前分支 RSS 增加 5.0%,但
Anonymous 下降 39.7%PSS 下降 1.0%。这说明差异主要体现
在非匿名或共享驻留页;本次没有保存逐 VMA 数据,因此不进一步
将它归因到某一个具体映射。
2. 两个相同版本进程运行在同一宿主机时会共享可执行文件代码页,
所以 PSS 比 RSS 更适合估算该测试拓扑的宿主机总成本,USS 和
Anonymous 更适合判断 EasyTier 私有堆及缓冲区的变化。
3. 这是一轮受控 A/B 测试,而不是长期统计分布。数值可用于确认
差异方向和量级;若作为发布门禁,应固定机器负载并增加多轮重复。
4. 本文只比较 QUIC TCP proxy 内存,不使用未限速吞吐结果推断性能,
避免吞吐差异污染内存结论。
@@ -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 }
@@ -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,6 @@
{
global:
Java_com_easytier_jni_EasyTierJNI_*;
local:
*;
};
@@ -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,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()
}
}
}
+216 -282
View File
@@ -1,319 +1,253 @@
use easytier::proto::api::manage::{NetworkInstanceRunningInfo, NetworkInstanceRunningInfoMap};
//! JNI facade for Android callers of EasyTier.
//!
//! This file intentionally lists every Java-visible native method exported by
//! `libeasytier_android_jni.so`. The implementation details live in sibling
//! modules so this facade stays readable as an API map.
//!
//! Network management APIs:
//! - `setTunFd(instanceName, fd)`: attach an Android TUN fd to an instance.
//! - `parseConfig(config)`: validate TOML config text.
//! - `runNetworkInstance(config)`: start a local network instance.
//! - `retainNetworkInstance(instanceNames)`: retain named instances and stop the rest.
//! - `listInstances()`: return running instance names and IDs as JSON.
//! - `collectNetworkInfos()`: return running instance info as a JSON string.
//! - `callJsonRpc(...)`: call an exposed EasyTier RPC service with JSON payload.
//!
//! Config server client APIs:
//! - `startConfigServerClient(url, hostname, machineId, secureMode, callback)`:
//! start the managed remote config client.
//! - `stopConfigServerClient()`: stop the managed client and release its Java callback.
//! - `isConfigServerClientConnected()`: return whether the managed client is connected.
//!
//! Error API:
//! - `getLastError()`: return the latest FFI/JNI error string for the calling thread.
//!
mod callback;
mod config_server_api;
mod error;
mod json_rpc_api;
mod logger;
mod network_api;
mod strings;
use jni::JNIEnv;
use jni::objects::{JClass, JObjectArray, JString};
use jni::sys::{jint, jstring};
use once_cell::sync::Lazy;
use std::ffi::{CStr, CString};
use std::ptr;
use jni::objects::{JClass, JObject, JObjectArray, JString};
use jni::sys::{jboolean, jint, jstring};
// 定义 KeyValuePair 结构体
#[repr(C)]
#[derive(Clone, Copy)]
pub struct KeyValuePair {
pub key: *const std::ffi::c_char,
pub value: *const std::ffi::c_char,
}
// 声明外部 C 函数
unsafe extern "C" {
fn set_tun_fd(inst_name: *const std::ffi::c_char, fd: std::ffi::c_int) -> std::ffi::c_int;
fn get_error_msg(out: *mut *const std::ffi::c_char);
fn free_string(s: *const std::ffi::c_char);
fn parse_config(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int;
fn run_network_instance(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int;
fn retain_network_instance(
inst_names: *const *const std::ffi::c_char,
length: usize,
) -> std::ffi::c_int;
fn collect_network_infos(infos: *mut KeyValuePair, max_length: usize) -> std::ffi::c_int;
}
// 初始化 Android 日志
static LOGGER_INIT: Lazy<()> = Lazy::new(|| {
android_logger::init_once(
android_logger::Config::default()
.with_max_level(log::LevelFilter::Debug)
.with_tag("EasyTier-JNI"),
);
});
// 辅助函数:从 Java String 转换为 CString
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())
}
// 辅助函数:获取错误消息
fn get_last_error() -> Option<String> {
unsafe {
let mut error_ptr: *const std::ffi::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)
}
}
}
// 辅助函数:抛出 Java 异常
fn throw_exception(env: &mut JNIEnv, message: &str) {
let _ = env.throw_new("java/lang/RuntimeException", message);
}
/// 设置 TUN 文件描述符
/// Attach a TUN file descriptor to an EasyTier network instance.
///
/// Java signature:
/// `EasyTierJNI.setTunFd(instanceName: String, fd: Int): Int`
///
/// `instanceName` must name an instance known to the shared FFI instance cache.
/// The `fd` must be a valid Android TUN file descriptor. On failure this
/// returns `-1` and throws `RuntimeException` with the FFI error message when
/// one is available.
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_setTunFd(
mut env: JNIEnv,
_class: JClass,
env: JNIEnv,
class: JClass,
inst_name: JString,
fd: jint,
) -> jint {
Lazy::force(&LOGGER_INIT);
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
}
logger::init();
network_api::set_tun_fd_jni(env, class, inst_name, fd)
}
/// 解析配置
/// Validate a TOML network config string.
///
/// Java signature:
/// `EasyTierJNI.parseConfig(config: String): Int`
///
/// This only validates the config text; it does not start or mutate any
/// instance. On failure this returns `-1` and throws `RuntimeException`.
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_parseConfig(
mut env: JNIEnv,
_class: JClass,
env: JNIEnv,
class: JClass,
config: JString,
) -> jint {
Lazy::force(&LOGGER_INIT);
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
}
logger::init();
network_api::parse_config_jni(env, class, config)
}
/// 运行网络实例
/// Start one local EasyTier network instance from TOML config text.
///
/// Java signature:
/// `EasyTierJNI.runNetworkInstance(config: String): Int`
///
/// The instance name in the config must be unique in the FFI instance cache.
/// On failure this returns `-1` and throws `RuntimeException`.
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_runNetworkInstance(
mut env: JNIEnv,
_class: JClass,
env: JNIEnv,
class: JClass,
config: JString,
) -> jint {
Lazy::force(&LOGGER_INIT);
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
}
logger::init();
network_api::run_network_instance_jni(env, class, config)
}
/// 保持网络实例
/// Retain the named network instances and stop all other instances.
///
/// Java signature:
/// `EasyTierJNI.retainNetworkInstance(instanceNames: Array<String>?): Int`
///
/// Passing `null` or an empty array stops all instances. Null elements inside a
/// non-empty array are invalid. On failure this returns `-1` and throws
/// `RuntimeException`.
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_retainNetworkInstance(
mut env: JNIEnv,
_class: JClass,
env: JNIEnv,
class: JClass,
instance_names: JObjectArray,
) -> jint {
Lazy::force(&LOGGER_INIT);
// 处理 null 数组的情况
if instance_names.is_null() {
unsafe {
let result = retain_network_instance(ptr::null(), 0);
if result != 0
&& let Some(error) = get_last_error()
{
throw_exception(&mut env, &error);
}
return result;
}
}
// 获取数组长度
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 {
unsafe {
let result = retain_network_instance(ptr::null(), 0);
if result != 0
&& let Some(error) = get_last_error()
{
throw_exception(&mut env, &error);
}
return result;
}
}
// 转换 Java 字符串数组为 C 字符串数组
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() {
continue; // 跳过 null 元素
}
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); // 保持 CString 的所有权
}
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
}
logger::init();
network_api::retain_network_instance_jni(env, class, instance_names)
}
/// 收集网络信息
/// Collect running network instance information.
///
/// Java signature:
/// `EasyTierJNI.collectNetworkInfos(maxLength: Int): String?`
///
/// Returns a JSON string containing `NetworkInstanceRunningInfoMap`, or null if
/// collection fails. `maxLength` limits how many FFI entries are collected. On
/// failure this throws `RuntimeException` when an error message is available.
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_collectNetworkInfos(
mut env: JNIEnv,
_class: JClass,
env: JNIEnv,
class: JClass,
max_length: jint,
) -> jstring {
Lazy::force(&LOGGER_INIT);
const MAX_INFOS: usize = 100;
let mut infos = vec![
KeyValuePair {
key: ptr::null(),
value: ptr::null(),
};
MAX_INFOS
];
unsafe {
let count = collect_network_infos(infos.as_mut_ptr(), MAX_INFOS);
if count < 0 {
if let Some(error) = get_last_error() {
throw_exception(&mut env, &error);
}
return ptr::null_mut();
}
let mut ret = NetworkInstanceRunningInfoMap::default();
// 使用 serde_json 构建 JSON
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();
let val = CStr::from_ptr(val_ptr).to_string_lossy();
let value = match serde_json::from_str::<NetworkInstanceRunningInfo>(val.as_ref()) {
Ok(v) => v,
Err(_) => {
throw_exception(&mut env, "Failed to parse JSON");
continue;
}
};
ret.map.insert(key.to_string(), 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()
}
}
}
logger::init();
network_api::collect_network_infos_jni(env, class, max_length)
}
/// 获取最后的错误信息
/// List running network instance names and IDs.
///
/// Java signature:
/// `EasyTierJNI.listInstances(maxLength: Int): String?`
///
/// Returns a JSON object whose keys are instance names and whose values are
/// instance ID strings. On failure this returns null and throws
/// `RuntimeException`.
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_listInstances(
env: JNIEnv,
class: JClass,
max_length: jint,
) -> jstring {
logger::init();
network_api::list_instances_jni(env, class, max_length)
}
/// Call an exposed EasyTier RPC method using protobuf JSON.
///
/// Java signature:
/// `EasyTierJNI.callJsonRpc(serviceName, methodName, domainName, payloadJson): String?`
///
/// Instance lifecycle management RPCs are intentionally not exposed here. Use
/// the dedicated EasyTierJNI instance APIs for start/retain/delete/collect.
/// `payloadJson` must include any `instance` selector required by the target
/// RPC. On failure this returns null and throws `RuntimeException`.
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_callJsonRpc(
env: JNIEnv,
class: JClass,
service_name: JString,
method_name: JString,
domain_name: JString,
payload_json: JString,
) -> jstring {
logger::init();
json_rpc_api::call_json_rpc_jni(
env,
class,
service_name,
method_name,
domain_name,
payload_json,
)
}
/// Return the latest FFI/JNI error string for the calling thread.
///
/// Java signature:
/// `EasyTierJNI.getLastError(): String?`
///
/// This combines the FFI thread-local error with any pending config-server Java
/// callback error. It returns null when no error is available.
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_getLastError(
env: JNIEnv,
_class: JClass,
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(),
}
error::get_last_error_jni(env, class)
}
/// Start the managed config-server client.
///
/// Java signature:
/// `EasyTierJNI.startConfigServerClient(url, hostname, machineId, secureMode, callback): Int`
///
/// JNI only converts Java values and keeps the Java callback alive. The FFI
/// layer owns singleton lifecycle, config-server/data-plane mutual exclusion,
/// remote instance tracking, and callback event timing. If `callback` is
/// non-null, each remote apply/delete event is delivered to
/// `ConfigServerEventCallback.onEvent(eventJson)`.
///
/// On failure this returns `-1` and throws `RuntimeException`.
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_startConfigServerClient(
mut env: JNIEnv,
_class: JClass,
config_server_url: JString,
hostname: JString,
machine_id: JString,
secure_mode: jboolean,
callback: JObject,
) -> jint {
logger::init();
config_server_api::start_config_server_client_jni(
&mut env,
config_server_url,
hostname,
machine_id,
secure_mode,
callback,
)
}
/// Stop the managed config-server client.
///
/// Java signature:
/// `EasyTierJNI.stopConfigServerClient(): Int`
///
/// The FFI layer performs the actual stop and managed instance cleanup. JNI
/// releases the Java callback reference after FFI stop succeeds. On failure
/// this returns `-1` and throws `RuntimeException`.
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_stopConfigServerClient(
env: JNIEnv,
class: JClass,
) -> jint {
logger::init();
config_server_api::stop_config_server_client_jni(env, class)
}
/// Report whether the managed config-server client is connected.
///
/// Java signature:
/// `EasyTierJNI.isConfigServerClientConnected(): Boolean`
///
/// Returns `JNI_TRUE` only when the FFI config-server client exists and reports
/// connected.
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_isConfigServerClientConnected(
env: JNIEnv,
class: JClass,
) -> jboolean {
logger::init();
config_server_api::is_config_server_client_connected_jni(env, class)
}
@@ -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)
}
+20 -3
View File
@@ -4,14 +4,31 @@ 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",
"easytier-core/proxy-smoltcp-stack",
]
macos-ne = ["easytier/macos-ne"]
[dependencies]
easytier = { path = "../../easytier" }
easytier = { path = "../../easytier", features = ["tracing-log"] }
easytier-core = { path = "../../easytier-core" }
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"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1"
uuid = "1.17.0"
[build-dependencies]
thunk-rs = { git = "https://github.com/easytier/thunk.git", default-features = false, features = [
"win7",
] }
@@ -0,0 +1,108 @@
# Native data-plane ABI v3
The native data-plane ABI is a thin adapter over the instance-owned
`DataPlaneSession`. It does not own sockets, operation state, completion
queues, routing policy, or timeouts.
## Conventions
- Every immediate call returns `0` on success or a negative
`DataPlaneErrorKind` value on failure.
- `data_plane_completion_wait` returns `1` when a completion is ready, `0` on
timeout or session close, and a negative error value on failure.
- `data_plane_completion_drain` returns a non-negative descriptor count or a
negative error value.
- Handle zero is invalid.
- `timeout_ms == UINT64_MAX` means no deadline.
- TCP connect/bind/accept and UDP bind timeouts start when submission is
accepted.
- TCP streams and UDP sockets have persistent read and write deadlines.
`data_plane_resource_deadline_set` replaces the selected directions'
deadlines immediately, including for active operations. An expired deadline
remains expired until it is replaced or cleared with `UINT64_MAX`.
- Deadline direction `1` selects reads, `2` selects writes, and `3` selects
both.
- Request and write bytes are copied before a submit call returns.
- Socket-address fields use native-endian integers. Address bytes are in
network order. ABI v3 accepts IPv4 only.
`DataPlaneSocketAddr` is:
```c
typedef struct {
uint16_t family; /* 4 */
uint16_t port;
uint8_t address[16]; /* IPv4 uses the first four bytes */
} DataPlaneSocketAddr;
```
`DataPlaneCompletion` is:
```c
typedef struct {
uint64_t operation_id;
uint16_t operation_kind;
uint16_t status; /* 0 or DataPlaneErrorKind */
} DataPlaneCompletion;
```
## Lifecycle
One native session may be open for an EasyTier instance at a time:
```text
data_plane_session_open
-> set resource deadlines
-> submit operations
-> completion_wait
-> completion_drain
-> typed result_take
-> resource_close / operation_free
data_plane_session_close
```
Closing a native session cancels and discards its outstanding operations and
resources and wakes a thread blocked in `data_plane_completion_wait`.
The resource and operation IDs returned by the ABI belong to that session.
They must always be passed together with the same session handle.
## Completion and result ownership
Submission returns an operation ID immediately. Completion descriptors carry
only the operation ID, operation kind, and terminal status. Draining a
descriptor makes its typed result available but does not consume it.
`data_plane_result_size` reports the TCP-read or UDP-receive payload size.
Typed result-take functions consume the result exactly once. If a supplied
buffer is too small, they return `-BufferTooSmall` and leave the result
available for a later call.
Call `data_plane_operation_free` when a drained result is intentionally
abandoned. Call `data_plane_resource_close` for TCP streams, listeners, and
UDP sockets.
## Operation kinds
| Value | Operation |
| ---: | --- |
| 1 | TCP connect |
| 2 | TCP bind |
| 3 | TCP accept |
| 4 | TCP read |
| 5 | TCP write |
| 6 | UDP bind |
| 7 | UDP receive |
| 8 | UDP send |
The exported function families are:
- `data_plane_tcp_*_submit`
- `data_plane_udp_*_submit`
- `data_plane_resource_deadline_set`
- `data_plane_completion_wait`
- `data_plane_completion_drain`
- `data_plane_*_result_take`
- `data_plane_operation_cancel`
- `data_plane_operation_free`
- `data_plane_resource_close`
+8
View File
@@ -0,0 +1,8 @@
fn main() {
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
if target_os == "windows" && (target_arch == "x86" || target_arch == "x86_64") {
thunk::thunk();
}
}
@@ -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,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,482 @@
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},
},
web_client::{WebClient, WebClientHooks, parse_config_server_endpoint, run_web_client},
};
use uuid::Uuid;
use crate::{
data_plane::remove_data_plane_sessions_by_instance_ids,
error::set_error_msg,
state::{ffi_context, resolve_instance_id_by_name},
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());
}
parse_config_server_endpoint(config_server_url_s)
.map(|_| ())
.map_err(|error| error.to_string())
}
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) =
resolve_instance_id_by_name(inst_name).map_err(|error| error.to_string())?
&& existing_id != inst_id
{
return Err(format!("instance name {} already exists", inst_name));
}
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 = ffi_context()
.manager
.instance(instance_id)
.map(|instance| instance.instance_name().to_owned())
.unwrap_or_default();
let network_name = ffi_context()
.manager
.config(instance_id)
.map(|config| config.get_network_identity().network_name)
.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())?;
if self.stopping.load(Ordering::Acquire) {
return Err("config server client is stopping".to_string());
}
let Some(inst_name) = ffi_context()
.manager
.instance(*id)
.map(|instance| instance.instance_name().to_owned())
else {
return Err(format!("instance {} not found after start", id));
};
self.instance_ids
.lock()
.map_err(|err| err.to_string())?
.insert(*id);
if let Err(error) = self.validate_instance_name(&inst_name, *id) {
self.remove_tracked_instance_ids(&[*id])?;
return Err(error);
}
remove_data_plane_sessions_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 = self.remove_tracked_instance_ids(ids)?;
remove_data_plane_sessions_by_instance_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 ffi_context().runtime.block_on(run_web_client(
&config_server_url,
config_server_machine_id_options(machine_id),
hostname,
secure_mode,
ffi_context().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 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();
// Keep the client discoverable until the canonical transaction drains its
// tracking. Earlier removals must still retire IDs from these same hooks.
drop(guard);
let delete_result = ffi_context().runtime.block_on(
ffi_context()
.process_management
.delete_owned_network_instances_selected_by(|| hooks.start_stopping()),
);
let managed = match CONFIG_SERVER_CLIENT.lock() {
Ok(mut guard) => guard.take(),
Err(err) => {
hooks.wait_for_callback_delivery();
CONFIG_SERVER_CLIENT_ACTIVE.store(false, Ordering::Release);
set_error_msg(&format!("failed to lock config server client: {err}"));
return -1;
}
};
drop(managed);
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,685 @@
use std::{
ffi::{c_char, c_int, c_uchar},
net::{IpAddr, Ipv4Addr, SocketAddr},
ptr,
};
use easytier_core::gateway::DataPlaneErrorKind;
use super::session::{self, NativeDataPlaneError, NativeDataPlaneResult};
use crate::{
error::set_error_msg,
strings::c_str_to_string,
types::{DataPlaneCompletion, DataPlaneSocketAddr},
};
pub const DATA_PLANE_DEADLINE_READ: u32 = 1 << 0;
pub const DATA_PLANE_DEADLINE_WRITE: u32 = 1 << 1;
fn failure(error: NativeDataPlaneError) -> c_int {
set_error_msg(&error.message);
-(error.kind as c_int)
}
fn status(result: NativeDataPlaneResult<()>) -> c_int {
match result {
Ok(()) => 0,
Err(error) => failure(error),
}
}
fn invalid(message: impl Into<String>) -> NativeDataPlaneError {
NativeDataPlaneError {
kind: DataPlaneErrorKind::Io,
message: message.into(),
}
}
fn socket_addr(address: DataPlaneSocketAddr) -> NativeDataPlaneResult<SocketAddr> {
let ip = match address.family {
4 => IpAddr::V4(Ipv4Addr::new(
address.address[0],
address.address[1],
address.address[2],
address.address[3],
)),
6 => {
return Err(NativeDataPlaneError {
kind: DataPlaneErrorKind::AddressFamilyUnsupported,
message: "IPv6 is not supported by data-plane ABI v3".to_string(),
});
}
family => {
return Err(NativeDataPlaneError {
kind: DataPlaneErrorKind::AddressFamilyUnsupported,
message: format!("unsupported address family {family}"),
});
}
};
Ok(SocketAddr::new(ip, address.port))
}
fn ffi_socket_addr(address: SocketAddr) -> DataPlaneSocketAddr {
match address.ip() {
IpAddr::V4(ip) => {
let mut bytes = [0; 16];
bytes[..4].copy_from_slice(&ip.octets());
DataPlaneSocketAddr {
family: 4,
port: address.port(),
address: bytes,
}
}
IpAddr::V6(ip) => DataPlaneSocketAddr {
family: 6,
port: address.port(),
address: ip.octets(),
},
}
}
unsafe fn copy_input(ptr: *const c_uchar, len: u32) -> NativeDataPlaneResult<Vec<u8>> {
if len == 0 {
return Ok(Vec::new());
}
if ptr.is_null() {
return Err(invalid("input buffer is null"));
}
Ok(unsafe { std::slice::from_raw_parts(ptr, len as usize) }.to_vec())
}
unsafe fn output_slice<'a>(ptr: *mut c_uchar, len: u32) -> NativeDataPlaneResult<&'a mut [u8]> {
if len == 0 {
return Ok(&mut []);
}
if ptr.is_null() {
return Err(invalid("output buffer is null"));
}
Ok(unsafe { std::slice::from_raw_parts_mut(ptr, len as usize) })
}
fn write_operation(
out_operation: *mut u64,
submit: impl FnOnce() -> NativeDataPlaneResult<u64>,
) -> c_int {
if out_operation.is_null() {
return failure(invalid("out_operation is null"));
}
match submit() {
Ok(operation) => {
unsafe {
*out_operation = operation;
}
0
}
Err(error) => failure(error),
}
}
/// # Safety
///
/// If non-null, `inst_name` must point to a valid NUL-terminated string.
/// `out_session` must be null or point to writable, properly aligned storage
/// for one `u64`.
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
pub unsafe extern "C" fn data_plane_session_open(
inst_name: *const c_char,
out_session: *mut u64,
) -> c_int {
if out_session.is_null() {
return failure(invalid("out_session is null"));
}
unsafe {
*out_session = 0;
}
let inst_name = match unsafe { c_str_to_string(inst_name, "inst_name") } {
Ok(inst_name) => inst_name,
Err(error) => return failure(invalid(error)),
};
match session::open(&inst_name) {
Ok(handle) => {
unsafe {
*out_session = handle;
}
0
}
Err(error) => failure(error),
}
}
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
pub extern "C" fn data_plane_session_close(session: u64) -> c_int {
status(super::session::close(session))
}
/// # Safety
///
/// `out_operation` must be null or point to writable, properly aligned
/// storage for one `u64`.
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
pub unsafe extern "C" fn data_plane_tcp_connect_submit(
session: u64,
peer_addr: DataPlaneSocketAddr,
timeout_ms: u64,
out_operation: *mut u64,
) -> c_int {
let peer_addr = match socket_addr(peer_addr) {
Ok(address) => address,
Err(error) => return failure(error),
};
write_operation(out_operation, || {
super::session::submit_tcp_connect(session, peer_addr, timeout_ms)
})
}
/// # Safety
///
/// `out_operation` must be null or point to writable, properly aligned
/// storage for one `u64`.
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
pub unsafe extern "C" fn data_plane_tcp_bind_submit(
session: u64,
local_port: u16,
timeout_ms: u64,
out_operation: *mut u64,
) -> c_int {
write_operation(out_operation, || {
super::session::submit_tcp_bind(session, local_port, timeout_ms)
})
}
/// # Safety
///
/// `out_operation` must be null or point to writable, properly aligned
/// storage for one `u64`.
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
pub unsafe extern "C" fn data_plane_tcp_accept_submit(
session: u64,
listener: u64,
timeout_ms: u64,
out_operation: *mut u64,
) -> c_int {
write_operation(out_operation, || {
super::session::submit_tcp_accept(session, listener, timeout_ms)
})
}
/// # Safety
///
/// `out_operation` must be null or point to writable, properly aligned
/// storage for one `u64`.
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
pub unsafe extern "C" fn data_plane_tcp_read_submit(
session: u64,
stream: u64,
max_len: u32,
out_operation: *mut u64,
) -> c_int {
write_operation(out_operation, || {
super::session::submit_tcp_read(session, stream, max_len)
})
}
/// # Safety
///
/// When `len` is nonzero, `data` must point to `len` readable bytes.
/// `out_operation` must be null or point to writable, properly aligned
/// storage for one `u64`.
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
pub unsafe extern "C" fn data_plane_tcp_write_submit(
session: u64,
stream: u64,
data: *const c_uchar,
len: u32,
out_operation: *mut u64,
) -> c_int {
let data = match unsafe { copy_input(data, len) } {
Ok(data) => data,
Err(error) => return failure(error),
};
write_operation(out_operation, || {
super::session::submit_tcp_write(session, stream, data)
})
}
/// # Safety
///
/// `out_operation` must be null or point to writable, properly aligned
/// storage for one `u64`.
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
pub unsafe extern "C" fn data_plane_udp_bind_submit(
session: u64,
local_port: u16,
timeout_ms: u64,
out_operation: *mut u64,
) -> c_int {
write_operation(out_operation, || {
super::session::submit_udp_bind(session, local_port, timeout_ms)
})
}
/// # Safety
///
/// `out_operation` must be null or point to writable, properly aligned
/// storage for one `u64`.
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
pub unsafe extern "C" fn data_plane_udp_receive_submit(
session: u64,
socket: u64,
max_len: u32,
out_operation: *mut u64,
) -> c_int {
write_operation(out_operation, || {
super::session::submit_udp_receive(session, socket, max_len)
})
}
/// # Safety
///
/// When `len` is nonzero, `data` must point to `len` readable bytes.
/// `out_operation` must be null or point to writable, properly aligned
/// storage for one `u64`.
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
pub unsafe extern "C" fn data_plane_udp_send_submit(
session: u64,
socket: u64,
peer_addr: DataPlaneSocketAddr,
data: *const c_uchar,
len: u32,
out_operation: *mut u64,
) -> c_int {
let peer_addr = match socket_addr(peer_addr) {
Ok(address) => address,
Err(error) => return failure(error),
};
let data = match unsafe { copy_input(data, len) } {
Ok(data) => data,
Err(error) => return failure(error),
};
write_operation(out_operation, || {
super::session::submit_udp_send(session, socket, peer_addr, data)
})
}
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
pub extern "C" fn data_plane_resource_deadline_set(
session: u64,
resource: u64,
direction: u32,
timeout_ms: u64,
) -> c_int {
let read = direction & DATA_PLANE_DEADLINE_READ != 0;
let write = direction & DATA_PLANE_DEADLINE_WRITE != 0;
if direction == 0 || direction & !(DATA_PLANE_DEADLINE_READ | DATA_PLANE_DEADLINE_WRITE) != 0 {
return failure(invalid(format!("invalid deadline direction {direction}")));
}
status(super::session::set_resource_deadline(
session, resource, read, write, timeout_ms,
))
}
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
pub extern "C" fn data_plane_operation_cancel(session: u64, operation: u64) -> c_int {
status(super::session::cancel_operation(session, operation))
}
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
pub extern "C" fn data_plane_operation_free(session: u64, operation: u64) -> c_int {
status(super::session::free_operation(session, operation))
}
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
pub extern "C" fn data_plane_resource_close(session: u64, resource: u64) -> c_int {
status(super::session::close_resource(session, resource))
}
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
pub extern "C" fn data_plane_completion_wait(session: u64, timeout_ms: u64) -> c_int {
match super::session::completion_wait(session, timeout_ms) {
Ok(true) => 1,
Ok(false) => 0,
Err(error) => failure(error),
}
}
/// # Safety
///
/// When `capacity` is nonzero, `completions` must point to writable, properly
/// aligned storage for `capacity` consecutive [`DataPlaneCompletion`] values.
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
pub unsafe extern "C" fn data_plane_completion_drain(
session: u64,
completions: *mut DataPlaneCompletion,
capacity: u32,
) -> c_int {
if capacity != 0 && completions.is_null() {
return failure(invalid("completions is null"));
}
let drained = match super::session::drain_completions(session, capacity as usize) {
Ok(drained) => drained,
Err(error) => return failure(error),
};
for (index, completion) in drained.iter().enumerate() {
unsafe {
ptr::write(
completions.add(index),
DataPlaneCompletion {
operation_id: completion.operation_id.get(),
operation_kind: completion.kind as u16,
status: completion.status.code(),
},
);
}
}
drained.len() as c_int
}
/// # Safety
///
/// `out_size` must be null or point to writable, properly aligned storage for
/// one `u32`.
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
pub unsafe extern "C" fn data_plane_result_size(
session: u64,
operation: u64,
out_size: *mut u32,
) -> c_int {
if out_size.is_null() {
return failure(invalid("out_size is null"));
}
match super::session::result_size(session, operation) {
Ok(size) => match u32::try_from(size) {
Ok(size) => {
unsafe {
*out_size = size;
}
0
}
Err(_) => failure(invalid("data-plane result size exceeds u32")),
},
Err(error) => failure(error),
}
}
/// # Safety
///
/// Each output pointer must be null or point to writable, properly aligned
/// storage for its pointee type. Non-null output locations must not overlap.
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
pub unsafe extern "C" fn data_plane_tcp_connect_result_take(
session: u64,
operation: u64,
out_stream: *mut u64,
out_local_addr: *mut DataPlaneSocketAddr,
out_peer_addr: *mut DataPlaneSocketAddr,
) -> c_int {
if out_stream.is_null() || out_local_addr.is_null() || out_peer_addr.is_null() {
return failure(invalid("TCP connect result output pointer is null"));
}
match super::session::take_tcp_connect(session, operation) {
Ok(result) => {
unsafe {
*out_stream = result.stream;
*out_local_addr = ffi_socket_addr(result.local_addr);
*out_peer_addr = ffi_socket_addr(result.peer_addr);
}
0
}
Err(error) => failure(error),
}
}
/// # Safety
///
/// Each output pointer must be null or point to writable, properly aligned
/// storage for its pointee type. Non-null output locations must not overlap.
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
pub unsafe extern "C" fn data_plane_tcp_bind_result_take(
session: u64,
operation: u64,
out_listener: *mut u64,
out_local_addr: *mut DataPlaneSocketAddr,
) -> c_int {
if out_listener.is_null() || out_local_addr.is_null() {
return failure(invalid("TCP bind result output pointer is null"));
}
match super::session::take_tcp_bind(session, operation) {
Ok(result) => {
unsafe {
*out_listener = result.listener;
*out_local_addr = ffi_socket_addr(result.local_addr);
}
0
}
Err(error) => failure(error),
}
}
/// # Safety
///
/// Each output pointer must be null or point to writable, properly aligned
/// storage for its pointee type. Non-null output locations must not overlap.
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
pub unsafe extern "C" fn data_plane_tcp_accept_result_take(
session: u64,
operation: u64,
out_stream: *mut u64,
out_local_addr: *mut DataPlaneSocketAddr,
out_peer_addr: *mut DataPlaneSocketAddr,
) -> c_int {
if out_stream.is_null() || out_local_addr.is_null() || out_peer_addr.is_null() {
return failure(invalid("TCP accept result output pointer is null"));
}
match super::session::take_tcp_accept(session, operation) {
Ok(result) => {
unsafe {
*out_stream = result.stream;
*out_local_addr = ffi_socket_addr(result.local_addr);
*out_peer_addr = ffi_socket_addr(result.peer_addr);
}
0
}
Err(error) => failure(error),
}
}
/// # Safety
///
/// When `capacity` is nonzero, `data` must point to `capacity` writable bytes.
/// Each scalar output pointer must be null or point to writable, properly
/// aligned storage for its pointee type. Non-null output ranges must not
/// overlap.
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
pub unsafe extern "C" fn data_plane_tcp_read_result_take(
session: u64,
operation: u64,
data: *mut c_uchar,
capacity: u32,
out_len: *mut u32,
out_eof: *mut bool,
) -> c_int {
if out_len.is_null() || out_eof.is_null() {
return failure(invalid("TCP read result output pointer is null"));
}
let data = match unsafe { output_slice(data, capacity) } {
Ok(data) => data,
Err(error) => return failure(error),
};
match super::session::take_tcp_read(session, operation, data) {
Ok(result) => {
unsafe {
*out_len = result.len as u32;
*out_eof = result.eof;
}
0
}
Err(error) => failure(error),
}
}
/// # Safety
///
/// `out_len` must be null or point to writable, properly aligned storage for
/// one `u32`.
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
pub unsafe extern "C" fn data_plane_tcp_write_result_take(
session: u64,
operation: u64,
out_len: *mut u32,
) -> c_int {
if out_len.is_null() {
return failure(invalid("out_len is null"));
}
match super::session::take_tcp_write(session, operation) {
Ok(len) => match u32::try_from(len) {
Ok(len) => {
unsafe {
*out_len = len;
}
0
}
Err(_) => failure(invalid("TCP write result exceeds u32")),
},
Err(error) => failure(error),
}
}
/// # Safety
///
/// Each output pointer must be null or point to writable, properly aligned
/// storage for its pointee type. Non-null output locations must not overlap.
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
pub unsafe extern "C" fn data_plane_udp_bind_result_take(
session: u64,
operation: u64,
out_socket: *mut u64,
out_local_addr: *mut DataPlaneSocketAddr,
) -> c_int {
if out_socket.is_null() || out_local_addr.is_null() {
return failure(invalid("UDP bind result output pointer is null"));
}
match super::session::take_udp_bind(session, operation) {
Ok(result) => {
unsafe {
*out_socket = result.socket;
*out_local_addr = ffi_socket_addr(result.local_addr);
}
0
}
Err(error) => failure(error),
}
}
/// # Safety
///
/// When `capacity` is nonzero, `data` must point to `capacity` writable bytes.
/// Each scalar output pointer must be null or point to writable, properly
/// aligned storage for its pointee type. Non-null output ranges must not
/// overlap.
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
pub unsafe extern "C" fn data_plane_udp_receive_result_take(
session: u64,
operation: u64,
data: *mut c_uchar,
capacity: u32,
out_len: *mut u32,
out_peer_addr: *mut DataPlaneSocketAddr,
out_truncated: *mut bool,
) -> c_int {
if out_len.is_null() || out_peer_addr.is_null() || out_truncated.is_null() {
return failure(invalid("UDP receive result output pointer is null"));
}
let data = match unsafe { output_slice(data, capacity) } {
Ok(data) => data,
Err(error) => return failure(error),
};
match super::session::take_udp_receive(session, operation, data) {
Ok(result) => {
unsafe {
*out_len = result.len as u32;
*out_peer_addr = ffi_socket_addr(result.peer_addr);
*out_truncated = result.truncated;
}
0
}
Err(error) => failure(error),
}
}
/// # Safety
///
/// `out_len` must be null or point to writable, properly aligned storage for
/// one `u32`.
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
pub unsafe extern "C" fn data_plane_udp_send_result_take(
session: u64,
operation: u64,
out_len: *mut u32,
) -> c_int {
if out_len.is_null() {
return failure(invalid("out_len is null"));
}
match super::session::take_udp_send(session, operation) {
Ok(len) => match u32::try_from(len) {
Ok(len) => {
unsafe {
*out_len = len;
}
0
}
Err(_) => failure(invalid("UDP send result exceeds u32")),
},
Err(error) => failure(error),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn socket_address_round_trip() {
let address = "127.0.0.1:1234".parse::<SocketAddr>().unwrap();
assert_eq!(socket_addr(ffi_socket_addr(address)).unwrap(), address);
}
#[test]
fn ipv6_is_rejected_by_v3() {
let error = socket_addr(ffi_socket_addr(
"[2001:db8::1]:4321".parse::<SocketAddr>().unwrap(),
))
.unwrap_err();
assert_eq!(error.kind, DataPlaneErrorKind::AddressFamilyUnsupported);
}
#[test]
fn invalid_address_family_is_stable() {
let error = socket_addr(DataPlaneSocketAddr {
family: 9,
..Default::default()
})
.unwrap_err();
assert_eq!(error.kind, DataPlaneErrorKind::AddressFamilyUnsupported);
}
#[test]
fn invalid_deadline_direction_is_rejected_before_session_lookup() {
let invalid = -(DataPlaneErrorKind::Io as c_int);
assert_eq!(data_plane_resource_deadline_set(u64::MAX, 1, 0, 0), invalid);
assert_eq!(data_plane_resource_deadline_set(u64::MAX, 1, 4, 0), invalid);
}
#[test]
fn null_operation_output_does_not_submit() {
let submitted = std::cell::Cell::new(false);
assert_eq!(
write_operation(std::ptr::null_mut(), || {
submitted.set(true);
Ok(1)
}),
-(DataPlaneErrorKind::Io as c_int)
);
assert!(!submitted.get());
}
}
@@ -0,0 +1,16 @@
//! Native C ABI adapter for the instance-scoped data-plane operation broker.
#[cfg(feature = "ffi-dataplane")]
mod abi;
#[cfg(feature = "ffi-dataplane")]
mod session;
#[cfg(feature = "ffi-dataplane")]
pub use abi::*;
#[cfg(feature = "ffi-dataplane")]
pub(crate) use session::{
lock_for_config_server_start, remove_data_plane_sessions_by_instance_ids,
};
#[cfg(not(feature = "ffi-dataplane"))]
pub(crate) fn remove_data_plane_sessions_by_instance_ids(_ids: &[uuid::Uuid]) {}
@@ -0,0 +1,646 @@
use std::{
collections::HashMap,
net::SocketAddr,
sync::{
Arc, Mutex, RwLock,
atomic::{AtomicBool, AtomicU64, Ordering},
},
time::Duration,
};
use easytier::instance::host::NativeInstanceHost;
use easytier_core::gateway::{
DataPlaneCompletionDescriptor, DataPlaneError, DataPlaneErrorKind, DataPlaneOperationId,
DataPlaneOperationKind, DataPlaneOperationResult, DataPlaneResourceId, DataPlaneSession,
};
use uuid::Uuid;
use crate::{
config_server::{in_config_server_callback, is_config_server_active_or_stopping},
state::{ffi_context, resolve_instance_id_by_name},
};
type CoreDataPlaneSession = DataPlaneSession<NativeInstanceHost>;
static NEXT_SESSION_HANDLE: AtomicU64 = AtomicU64::new(1);
static SESSIONS: once_cell::sync::Lazy<Mutex<HashMap<u64, Arc<NativeDataPlaneSession>>>> =
once_cell::sync::Lazy::new(|| Mutex::new(HashMap::new()));
static DATA_PLANE_USAGE_LOCK: once_cell::sync::Lazy<RwLock<()>> =
once_cell::sync::Lazy::new(|| RwLock::new(()));
#[derive(Debug)]
pub(super) struct NativeDataPlaneError {
pub(super) kind: DataPlaneErrorKind,
pub(super) message: String,
}
impl NativeDataPlaneError {
fn new(kind: DataPlaneErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
fn invalid(message: impl Into<String>) -> Self {
Self::new(DataPlaneErrorKind::Io, message)
}
fn closed(message: impl Into<String>) -> Self {
Self::new(DataPlaneErrorKind::HandleClosed, message)
}
}
impl From<DataPlaneError> for NativeDataPlaneError {
fn from(error: DataPlaneError) -> Self {
Self::new(error.kind(), error.message())
}
}
pub(super) type NativeDataPlaneResult<T> = Result<T, NativeDataPlaneError>;
pub(super) struct TcpConnectResult {
pub(super) stream: u64,
pub(super) local_addr: SocketAddr,
pub(super) peer_addr: SocketAddr,
}
pub(super) struct TcpBindResult {
pub(super) listener: u64,
pub(super) local_addr: SocketAddr,
}
pub(super) struct TcpAcceptResult {
pub(super) stream: u64,
pub(super) local_addr: SocketAddr,
pub(super) peer_addr: SocketAddr,
}
pub(super) struct TcpReadResult {
pub(super) len: usize,
pub(super) eof: bool,
}
pub(super) struct UdpBindResult {
pub(super) socket: u64,
pub(super) local_addr: SocketAddr,
}
pub(super) struct UdpReceiveResult {
pub(super) len: usize,
pub(super) peer_addr: SocketAddr,
pub(super) truncated: bool,
}
struct NativeDataPlaneSession {
instance_id: Uuid,
runtime: tokio::runtime::Handle,
core: Arc<CoreDataPlaneSession>,
submit_gate: Mutex<()>,
closed: AtomicBool,
}
impl NativeDataPlaneSession {
fn close(&self) {
let _gate = self
.submit_gate
.lock()
.unwrap_or_else(|error| error.into_inner());
if self.closed.swap(true, Ordering::AcqRel) {
return;
}
self.core.discard_all();
}
fn call<T>(
&self,
call: impl FnOnce(&Arc<CoreDataPlaneSession>) -> Result<T, DataPlaneError>,
) -> NativeDataPlaneResult<T> {
let _gate = self
.submit_gate
.lock()
.map_err(|error| NativeDataPlaneError::invalid(error.to_string()))?;
if self.closed.load(Ordering::Acquire) {
return Err(NativeDataPlaneError::closed(
"native data-plane session is closed",
));
}
let _runtime = self.runtime.enter();
call(&self.core).map_err(Into::into)
}
fn submit(
&self,
submit: impl FnOnce(&Arc<CoreDataPlaneSession>) -> Result<DataPlaneOperationId, DataPlaneError>,
) -> NativeDataPlaneResult<u64> {
self.call(submit).map(DataPlaneOperationId::get)
}
}
fn sessions()
-> NativeDataPlaneResult<std::sync::MutexGuard<'static, HashMap<u64, Arc<NativeDataPlaneSession>>>>
{
SESSIONS
.lock()
.map_err(|error| NativeDataPlaneError::invalid(error.to_string()))
}
fn get_session(handle: u64) -> NativeDataPlaneResult<Arc<NativeDataPlaneSession>> {
if handle == 0 {
return Err(NativeDataPlaneError::closed(
"native data-plane session handle is invalid",
));
}
let session = sessions()?
.get(&handle)
.cloned()
.ok_or_else(|| NativeDataPlaneError::closed("native data-plane session is closed"))?;
if session.closed.load(Ordering::Acquire) {
return Err(NativeDataPlaneError::closed(
"native data-plane session is closed",
));
}
Ok(session)
}
fn next_session_handle(
sessions: &HashMap<u64, Arc<NativeDataPlaneSession>>,
) -> NativeDataPlaneResult<u64> {
for _ in 0..sessions.len().saturating_add(2) {
let handle = NEXT_SESSION_HANDLE.fetch_add(1, Ordering::Relaxed);
if handle != 0 && !sessions.contains_key(&handle) {
return Ok(handle);
}
}
Err(NativeDataPlaneError::new(
DataPlaneErrorKind::ResourceLimit,
"native data-plane session handle space is exhausted",
))
}
fn reject_data_plane_use() -> NativeDataPlaneResult<()> {
if in_config_server_callback() {
Err(NativeDataPlaneError::invalid(
"cannot use data plane from config server callback",
))
} else if is_config_server_active_or_stopping() {
Err(NativeDataPlaneError::invalid(
"cannot use data plane while config server client is active",
))
} else {
Ok(())
}
}
pub(super) fn open(inst_name: &str) -> NativeDataPlaneResult<u64> {
reject_data_plane_use()?;
let _usage = DATA_PLANE_USAGE_LOCK
.read()
.map_err(|error| NativeDataPlaneError::invalid(error.to_string()))?;
reject_data_plane_use()?;
let instance_id = resolve_instance_id_by_name(inst_name)
.map_err(NativeDataPlaneError::invalid)?
.ok_or_else(|| NativeDataPlaneError::closed("instance not found"))?;
let manager = &ffi_context().manager;
let core = manager.data_plane_session(&instance_id).ok_or_else(|| {
NativeDataPlaneError::closed("instance data-plane session is unavailable")
})?;
let runtime = manager
.data_plane_runtime_handle(&instance_id)
.ok_or_else(|| NativeDataPlaneError::closed("instance runtime is unavailable"))?;
let mut sessions = sessions()?;
if sessions
.values()
.any(|session| session.instance_id == instance_id)
{
return Err(NativeDataPlaneError::new(
DataPlaneErrorKind::ResourceLimit,
"instance already has an open native data-plane session",
));
}
let handle = next_session_handle(&sessions)?;
sessions.insert(
handle,
Arc::new(NativeDataPlaneSession {
instance_id,
runtime,
core,
submit_gate: Mutex::new(()),
closed: AtomicBool::new(false),
}),
);
Ok(handle)
}
pub(super) fn close(handle: u64) -> NativeDataPlaneResult<()> {
let _usage = DATA_PLANE_USAGE_LOCK
.read()
.map_err(|error| NativeDataPlaneError::invalid(error.to_string()))?;
let mut sessions = sessions()?;
let session = sessions
.remove(&handle)
.ok_or_else(|| NativeDataPlaneError::closed("native data-plane session is closed"))?;
// Keep the registry locked until the shared core namespace is empty. An
// open for the same instance must not publish a replacement session before
// this old wrapper finishes discarding its operations and resources.
session.close();
Ok(())
}
fn timeout(timeout_ms: u64) -> Option<Duration> {
(timeout_ms != u64::MAX).then(|| Duration::from_millis(timeout_ms))
}
fn operation_id(raw: u64) -> NativeDataPlaneResult<DataPlaneOperationId> {
DataPlaneOperationId::from_raw(raw)
.ok_or_else(|| NativeDataPlaneError::closed("data-plane operation handle is invalid"))
}
fn resource_id(raw: u64) -> NativeDataPlaneResult<DataPlaneResourceId> {
DataPlaneResourceId::from_raw(raw)
.ok_or_else(|| NativeDataPlaneError::closed("data-plane resource handle is invalid"))
}
pub(super) fn submit_tcp_connect(
session: u64,
peer_addr: SocketAddr,
timeout_ms: u64,
) -> NativeDataPlaneResult<u64> {
get_session(session)?.submit(|core| core.submit_tcp_connect(peer_addr, timeout(timeout_ms)))
}
pub(super) fn submit_tcp_bind(
session: u64,
local_port: u16,
timeout_ms: u64,
) -> NativeDataPlaneResult<u64> {
get_session(session)?.submit(|core| core.submit_tcp_bind(local_port, timeout(timeout_ms)))
}
pub(super) fn submit_tcp_accept(
session: u64,
listener: u64,
timeout_ms: u64,
) -> NativeDataPlaneResult<u64> {
let listener = resource_id(listener)?;
get_session(session)?.submit(|core| core.submit_tcp_accept(listener, timeout(timeout_ms)))
}
pub(super) fn submit_tcp_read(
session: u64,
stream: u64,
max_len: u32,
) -> NativeDataPlaneResult<u64> {
let stream = resource_id(stream)?;
get_session(session)?.submit(|core| core.submit_tcp_read(stream, max_len as usize))
}
pub(super) fn submit_tcp_write(
session: u64,
stream: u64,
data: Vec<u8>,
) -> NativeDataPlaneResult<u64> {
let stream = resource_id(stream)?;
get_session(session)?.submit(|core| core.submit_tcp_write(stream, data))
}
pub(super) fn submit_udp_bind(
session: u64,
local_port: u16,
timeout_ms: u64,
) -> NativeDataPlaneResult<u64> {
get_session(session)?.submit(|core| core.submit_udp_bind(local_port, timeout(timeout_ms)))
}
pub(super) fn submit_udp_receive(
session: u64,
socket: u64,
max_len: u32,
) -> NativeDataPlaneResult<u64> {
let socket = resource_id(socket)?;
get_session(session)?.submit(|core| core.submit_udp_receive(socket, max_len as usize))
}
pub(super) fn submit_udp_send(
session: u64,
socket: u64,
peer_addr: SocketAddr,
data: Vec<u8>,
) -> NativeDataPlaneResult<u64> {
let socket = resource_id(socket)?;
get_session(session)?.submit(|core| core.submit_udp_send(socket, peer_addr, data))
}
pub(super) fn set_resource_deadline(
session: u64,
resource: u64,
read: bool,
write: bool,
timeout_ms: u64,
) -> NativeDataPlaneResult<()> {
let resource = resource_id(resource)?;
get_session(session)?
.call(|core| core.set_resource_deadline(resource, read, write, timeout(timeout_ms)))
}
pub(super) fn cancel_operation(session: u64, operation: u64) -> NativeDataPlaneResult<()> {
let operation = operation_id(operation)?;
get_session(session)?.core.cancel_operation(operation);
Ok(())
}
pub(super) fn free_operation(session: u64, operation: u64) -> NativeDataPlaneResult<()> {
let operation = operation_id(operation)?;
get_session(session)?.core.free_operation(operation);
Ok(())
}
pub(super) fn close_resource(session: u64, resource: u64) -> NativeDataPlaneResult<()> {
let resource = resource_id(resource)?;
get_session(session)?.core.close_resource(resource);
Ok(())
}
pub(super) fn completion_wait(session: u64, timeout_ms: u64) -> NativeDataPlaneResult<bool> {
let session = get_session(session)?;
let ready = session.core.completion_wait(timeout(timeout_ms));
Ok(ready && !session.closed.load(Ordering::Acquire))
}
pub(super) fn drain_completions(
session: u64,
max_count: usize,
) -> NativeDataPlaneResult<Vec<DataPlaneCompletionDescriptor>> {
Ok(get_session(session)?.core.drain_completions(max_count))
}
pub(super) fn result_size(session: u64, operation: u64) -> NativeDataPlaneResult<usize> {
let operation = operation_id(operation)?;
get_session(session)?
.core
.result_payload_bytes(operation)
.map_err(Into::into)
}
fn take_result<T>(
session: u64,
operation: u64,
expected: DataPlaneOperationKind,
take: impl FnOnce(&DataPlaneOperationResult) -> Option<T>,
) -> NativeDataPlaneResult<T> {
let operation = operation_id(operation)?;
let session = get_session(session)?;
let actual = session.core.operation_kind(operation)?;
if actual != expected {
return Err(NativeDataPlaneError::invalid(format!(
"operation kind mismatch: expected {expected:?}, got {actual:?}"
)));
}
let result = session.core.take_result_with(operation, |outcome| {
Some(match outcome {
Ok(result) => take(result).ok_or_else(|| {
NativeDataPlaneError::invalid("data-plane result variant does not match operation")
}),
Err(kind) => Err(NativeDataPlaneError::new(
*kind,
format!("data-plane operation failed with {kind:?}"),
)),
})
})?;
result
.ok_or_else(|| NativeDataPlaneError::invalid("data-plane result could not be consumed"))?
}
pub(super) fn take_tcp_connect(
session: u64,
operation: u64,
) -> NativeDataPlaneResult<TcpConnectResult> {
take_result(
session,
operation,
DataPlaneOperationKind::TcpConnect,
|result| match result {
DataPlaneOperationResult::TcpConnected {
stream,
local_addr,
peer_addr,
} => Some(TcpConnectResult {
stream: stream.get(),
local_addr: *local_addr,
peer_addr: *peer_addr,
}),
_ => None,
},
)
}
pub(super) fn take_tcp_bind(session: u64, operation: u64) -> NativeDataPlaneResult<TcpBindResult> {
take_result(
session,
operation,
DataPlaneOperationKind::TcpBind,
|result| match result {
DataPlaneOperationResult::TcpBound {
listener,
local_addr,
} => Some(TcpBindResult {
listener: listener.get(),
local_addr: *local_addr,
}),
_ => None,
},
)
}
pub(super) fn take_tcp_accept(
session: u64,
operation: u64,
) -> NativeDataPlaneResult<TcpAcceptResult> {
take_result(
session,
operation,
DataPlaneOperationKind::TcpAccept,
|result| match result {
DataPlaneOperationResult::TcpAccepted {
stream,
local_addr,
peer_addr,
} => Some(TcpAcceptResult {
stream: stream.get(),
local_addr: *local_addr,
peer_addr: *peer_addr,
}),
_ => None,
},
)
}
pub(super) fn take_tcp_read(
session: u64,
operation: u64,
output: &mut [u8],
) -> NativeDataPlaneResult<TcpReadResult> {
let required = result_size(session, operation)?;
if output.len() < required {
return Err(NativeDataPlaneError::new(
DataPlaneErrorKind::BufferTooSmall,
format!(
"TCP read result requires {required} bytes, buffer has {}",
output.len()
),
));
}
take_result(
session,
operation,
DataPlaneOperationKind::TcpRead,
|result| match result {
DataPlaneOperationResult::TcpRead { data, eof } => {
output[..data.len()].copy_from_slice(data);
Some(TcpReadResult {
len: data.len(),
eof: *eof,
})
}
_ => None,
},
)
}
pub(super) fn take_tcp_write(session: u64, operation: u64) -> NativeDataPlaneResult<usize> {
take_result(
session,
operation,
DataPlaneOperationKind::TcpWrite,
|result| match result {
DataPlaneOperationResult::TcpWritten { len } => Some(*len),
_ => None,
},
)
}
pub(super) fn take_udp_bind(session: u64, operation: u64) -> NativeDataPlaneResult<UdpBindResult> {
take_result(
session,
operation,
DataPlaneOperationKind::UdpBind,
|result| match result {
DataPlaneOperationResult::UdpBound { socket, local_addr } => Some(UdpBindResult {
socket: socket.get(),
local_addr: *local_addr,
}),
_ => None,
},
)
}
pub(super) fn take_udp_receive(
session: u64,
operation: u64,
output: &mut [u8],
) -> NativeDataPlaneResult<UdpReceiveResult> {
let required = result_size(session, operation)?;
if output.len() < required {
return Err(NativeDataPlaneError::new(
DataPlaneErrorKind::BufferTooSmall,
format!(
"UDP receive result requires {required} bytes, buffer has {}",
output.len()
),
));
}
take_result(
session,
operation,
DataPlaneOperationKind::UdpReceive,
|result| match result {
DataPlaneOperationResult::UdpReceived {
data,
peer_addr,
truncated,
} => {
output[..data.len()].copy_from_slice(data);
Some(UdpReceiveResult {
len: data.len(),
peer_addr: *peer_addr,
truncated: *truncated,
})
}
_ => None,
},
)
}
pub(super) fn take_udp_send(session: u64, operation: u64) -> NativeDataPlaneResult<usize> {
take_result(
session,
operation,
DataPlaneOperationKind::UdpSend,
|result| match result {
DataPlaneOperationResult::UdpSent { len } => Some(*len),
_ => None,
},
)
}
pub(crate) fn remove_data_plane_sessions_by_instance_ids(ids: &[Uuid]) {
if ids.is_empty() {
return;
}
let _usage = DATA_PLANE_USAGE_LOCK
.write()
.unwrap_or_else(|error| error.into_inner());
let removed = {
let mut sessions = SESSIONS.lock().unwrap_or_else(|error| error.into_inner());
let handles = sessions
.iter()
.filter_map(|(handle, session)| ids.contains(&session.instance_id).then_some(*handle))
.collect::<Vec<_>>();
handles
.into_iter()
.filter_map(|handle| sessions.remove(&handle))
.collect::<Vec<_>>()
};
for session in removed {
session.close();
}
}
pub(crate) fn lock_for_config_server_start()
-> Result<std::sync::RwLockWriteGuard<'static, ()>, String> {
let guard = DATA_PLANE_USAGE_LOCK
.write()
.map_err(|error| format!("failed to lock data plane usage: {error}"))?;
if !SESSIONS
.lock()
.map_err(|error| format!("failed to lock data-plane sessions: {error}"))?
.is_empty()
{
return Err("cannot start config server client while data plane is in use".to_string());
}
Ok(guard)
}
#[cfg(test)]
mod tests {
use std::{sync::mpsc, time::Duration};
use super::*;
#[test]
fn config_server_start_waits_for_session_open_or_close() {
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();
}
}
@@ -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,350 @@
use std::ffi::{CString, c_char, c_int};
#[cfg(any(
target_os = "android",
target_os = "ios",
all(target_os = "macos", feature = "macos-ne"),
target_env = "ohos"
))]
use easytier::common::config::ConfigLoader as _;
use easytier::common::config::{ConfigFileControl, TomlConfigLoader};
use crate::{
config_server::{in_config_server_callback, wait_for_config_server_delivery},
error::set_error_msg,
state::{ffi_context, resolve_instance_id_by_name},
types::KeyValuePair,
};
#[cfg(any(
target_os = "android",
target_os = "ios",
all(target_os = "macos", feature = "macos-ne"),
target_env = "ohos"
))]
fn mobile_tun_sources_for_legacy_set_tun_fd(inst_id: uuid::Uuid) -> Result<(), String> {
let config = ffi_context()
.manager
.config(inst_id)
.ok_or_else(|| format!("instance config unavailable: {inst_id}"))?;
let flags = config.get_flags();
if flags.dev_name.is_empty() {
return Ok(());
}
Err(format!(
"set_tun_fd legacy API cannot attach shared mobile TUN dev_name={} without tun sources",
flags.dev_name
))
}
/// # 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()
};
let inst_id = match resolve_instance_id_by_name(&inst_name) {
Ok(Some(instance_id)) => instance_id,
Ok(None) => {
set_error_msg(&format!("instance not found: {inst_name}"));
return -1;
}
Err(error) => {
set_error_msg(&error.to_string());
return -1;
}
};
#[cfg(any(
target_os = "android",
target_os = "ios",
all(target_os = "macos", feature = "macos-ne"),
target_env = "ohos"
))]
if let Err(error) = mobile_tun_sources_for_legacy_set_tun_fd(inst_id) {
set_error_msg(&error);
return -1;
}
match ffi_context().manager.attach_tun_fd(inst_id, fd) {
Ok(_) => 0,
Err(e) => {
set_error_msg(&format!("failed to set tun fd: {}", e));
-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;
}
};
wait_for_config_server_delivery();
if let Err(e) = ffi_context().runtime.block_on(
ffi_context()
.process_management
.run_owned_network_instance(cfg, ConfigFileControl::STATIC_CONFIG),
) {
set_error_msg(&format!("failed to start instance: {}", e));
return -1;
}
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 retained_names = if length == 0 {
Vec::new()
} else {
let Some(inst_names) = (unsafe { parse_instance_names(inst_names, length) }) else {
return -1;
};
inst_names
};
if let Err(error) = ffi_context().runtime.block_on(
ffi_context()
.process_management
.retain_owned_network_instances_by_name(retained_names),
) {
set_error_msg(&format!("failed to retain instances: {error}"));
return -1;
}
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();
if length == 0 {
return 0;
}
let Some(inst_names) = (unsafe { parse_instance_names(inst_names, length) }) else {
return -1;
};
if let Err(error) = ffi_context().runtime.block_on(
ffi_context()
.process_management
.delete_owned_network_instances_by_name(inst_names),
) {
set_error_msg(&format!("failed to delete instances: {error}"));
return -1;
}
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 ffi_context().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) = ffi_context()
.manager
.instance(*instance_id)
.map(|instance| instance.instance_name().to_owned())
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 = ffi_context()
.manager
.instance_ids()
.into_iter()
.filter_map(|id| {
ffi_context()
.manager
.instance(id)
.map(|instance| (instance.instance_name().to_owned(), 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,107 @@
use std::{
ffi::{CString, c_char, c_int},
sync::Arc,
};
use crate::{
config_server::in_config_server_callback,
error::set_error_msg,
state::ffi_context,
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 ffi_context()
.runtime
.block_on(easytier_core::management::call_management_json_rpc(
&ffi_context().manager,
Arc::new(easytier::rpc_service::logger::NativeLoggerControl),
&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
}
+333 -242
View File
@@ -1,267 +1,358 @@
use std::sync::Mutex;
//! C ABI facade for EasyTier.
//!
//! The exported API is intentionally kept in this file so C users and JNI
//! bindings can see the full callable surface without reading the internal
//! implementation modules.
//!
//! Network management APIs:
//! - `parse_config`: validate a TOML network config string.
//! - `run_network_instance`: start one local network instance from TOML.
//! - `retain_network_instance`: keep named instances and stop all others.
//! - `delete_network_instance`: stop named local network instances.
//! - `list_instance`: list running instance names and IDs.
//! - `collect_network_infos`: collect running instance info as key/value pairs.
//! - `set_tun_fd`: attach a TUN file descriptor to a named instance.
//! - `call_json_rpc`: call an exposed EasyTier RPC service with JSON payload.
//!
//! Config server client APIs:
//! - `start_config_server_client`: start the managed remote config client.
//! - `stop_config_server_client`: stop the remote config client and its managed instances.
//! - `is_config_server_client_connected`: report whether the client is connected.
//!
//! Data plane APIs, enabled by the `ffi-dataplane` feature:
//! - `data_plane_session_open` / `data_plane_session_close`: own one instance session.
//! - `data_plane_*_submit`: submit non-blocking TCP and UDP operations.
//! - `data_plane_completion_wait` / `data_plane_completion_drain`: await completions.
//! - `data_plane_*_result_take`: consume typed operation results.
//! - `data_plane_operation_cancel` / `data_plane_operation_free`: control operations.
//! - `data_plane_resource_close`: close streams, listeners, and UDP sockets.
//!
//! Shared FFI helper APIs:
//! - `get_error_msg`: copy the last FFI or config-server callback error message.
//! - `free_string`: release strings allocated by this library.
use dashmap::DashMap;
use easytier::{
common::config::{ConfigFileControl, ConfigLoader as _, TomlConfigLoader},
instance_manager::NetworkInstanceManager,
mod config_server;
mod data_plane;
mod error;
mod instance_api;
mod json_rpc;
mod state;
mod strings;
mod types;
#[cfg(test)]
mod tests;
pub use config_server::{in_config_server_callback, validate_config_server_client_options};
pub use types::{
ConfigServerEventCallback, DataPlaneCompletion, DataPlaneSocketAddr, KeyValuePair,
};
static INSTANCE_NAME_ID_MAP: once_cell::sync::Lazy<DashMap<String, uuid::Uuid>> =
once_cell::sync::Lazy::new(DashMap::new);
static INSTANCE_MANAGER: once_cell::sync::Lazy<NetworkInstanceManager> =
once_cell::sync::Lazy::new(NetworkInstanceManager::new);
use std::ffi::{c_char, c_int, c_void};
static ERROR_MSG: once_cell::sync::Lazy<Mutex<Vec<u8>>> =
once_cell::sync::Lazy::new(|| Mutex::new(Vec::new()));
#[repr(C)]
pub struct KeyValuePair {
pub key: *const std::ffi::c_char,
pub value: *const std::ffi::c_char,
}
fn set_error_msg(msg: &str) {
let bytes = msg.as_bytes();
let mut msg_buf = ERROR_MSG.lock().unwrap();
let len = bytes.len();
msg_buf.resize(len, 0);
msg_buf[..len].copy_from_slice(bytes);
}
// ===== Network Management API =====
/// Validate a TOML network config string.
///
/// This only parses and validates the config. It does not start an instance and
/// does not change global FFI state.
///
/// # Safety
/// Set the tun fd
#[unsafe(no_mangle)]
pub unsafe extern "C" fn set_tun_fd(
inst_name: *const std::ffi::c_char,
fd: std::ffi::c_int,
) -> std::ffi::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,
}
/// `cfg_str` must be a non-null pointer to a null-terminated UTF-8 string.
///
/// # Return
/// Returns `0` if the config parses successfully, or `-1` on failure. On
/// failure, call `get_error_msg` on the same thread to retrieve details.
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
pub unsafe extern "C" fn parse_config(cfg_str: *const c_char) -> c_int {
unsafe { instance_api::parse_config(cfg_str) }
}
/// Start one local EasyTier network instance from a TOML config string.
///
/// The config's `inst_name` must be unique among instances started through this
/// FFI layer. This API is mutually exclusive with config-server callback
/// execution and will fail if called from a config-server event callback.
///
/// # Safety
/// Get the last error message
#[unsafe(no_mangle)]
pub unsafe extern "C" fn get_error_msg(out: *mut *const std::ffi::c_char) {
let msg_buf = ERROR_MSG.lock().unwrap();
if msg_buf.is_empty() {
unsafe {
*out = std::ptr::null();
}
return;
}
let cstr = std::ffi::CString::new(&msg_buf[..]).unwrap();
unsafe {
*out = cstr.into_raw();
}
}
#[unsafe(no_mangle)]
pub extern "C" fn free_string(s: *const std::ffi::c_char) {
if s.is_null() {
return;
}
unsafe {
let _ = std::ffi::CString::from_raw(s as *mut std::ffi::c_char);
}
/// `cfg_str` must be a non-null pointer to a null-terminated UTF-8 string.
///
/// # Return
/// Returns `0` after the instance is started and registered in the FFI name
/// cache, or `-1` on failure. On failure, call `get_error_msg` on the same
/// thread to retrieve details.
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
pub unsafe extern "C" fn run_network_instance(cfg_str: *const c_char) -> c_int {
unsafe { instance_api::run_network_instance(cfg_str) }
}
/// Keep the named network instances and stop all other instances.
///
/// Passing `length == 0` stops all instances. When `length > 0`, `inst_names`
/// must point to an array of `length` non-null C strings. Instances that are not
/// retained are removed from the FFI name cache and any related data-plane
/// handles are closed.
///
/// This API fails if called from a config-server event callback.
///
/// # Safety
/// Parse the config
#[unsafe(no_mangle)]
pub unsafe extern "C" 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
#[unsafe(no_mangle)]
pub unsafe extern "C" fn run_network_instance(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()
};
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();
if INSTANCE_NAME_ID_MAP.contains_key(&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
}
/// # Safety
/// Retain the network instance
#[unsafe(no_mangle)]
/// If `length > 0`, `inst_names` must be a non-null pointer to an array of
/// `length` non-null pointers to null-terminated UTF-8 strings.
///
/// # Return
/// Returns `0` on success, or `-1` on failure. On failure, call
/// `get_error_msg` on the same thread to retrieve details.
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
pub unsafe extern "C" fn retain_network_instance(
inst_names: *const *const std::ffi::c_char,
inst_names: *const *const c_char,
length: usize,
) -> std::ffi::c_int {
if length == 0 {
if let Err(e) = INSTANCE_MANAGER.retain_network_instance(Vec::new()) {
set_error_msg(&format!("failed to retain instances: {}", e));
return -1;
}
INSTANCE_NAME_ID_MAP.clear();
return 0;
}
let inst_names = unsafe {
assert!(!inst_names.is_null());
std::slice::from_raw_parts(inst_names, length)
.iter()
.map(|&name| {
assert!(!name.is_null());
std::ffi::CStr::from_ptr(name)
.to_string_lossy()
.into_owned()
})
.collect::<Vec<_>>()
};
let inst_ids: Vec<uuid::Uuid> = inst_names
.iter()
.filter_map(|name| INSTANCE_NAME_ID_MAP.get(name).map(|id| *id))
.collect();
if let Err(e) = INSTANCE_MANAGER.retain_network_instance(inst_ids) {
set_error_msg(&format!("failed to retain instances: {}", e));
return -1;
}
INSTANCE_NAME_ID_MAP.retain(|k, _| inst_names.contains(k));
0
) -> c_int {
unsafe { instance_api::retain_network_instance(inst_names, length) }
}
/// Stop the named network instances.
///
/// Passing `length == 0` is a no-op. When `length > 0`, `inst_names` must point
/// to an array of `length` non-null C strings. Unknown names are ignored.
/// Removed instances are also removed from the FFI name cache and any related
/// data-plane handles are closed.
///
/// This API fails if called from a config-server event callback.
///
/// # Safety
/// Collect the network infos
#[unsafe(no_mangle)]
/// If `length > 0`, `inst_names` must be a non-null pointer to an array of
/// `length` non-null pointers to null-terminated UTF-8 strings.
///
/// # Return
/// Returns `0` on success, or `-1` on failure. On failure, call
/// `get_error_msg` on the same thread to retrieve details.
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
pub unsafe extern "C" fn delete_network_instance(
inst_names: *const *const c_char,
length: usize,
) -> c_int {
unsafe { instance_api::delete_network_instance(inst_names, length) }
}
/// List running network instance names and IDs.
///
/// Writes up to `max_length` entries into `infos`. Each returned key is the
/// instance name and each returned value is the instance ID string. Returned
/// key/value strings are allocated by this library and must be released with
/// `free_string`.
///
/// This API fails if called from a config-server event callback.
///
/// # Safety
/// If `max_length > 0`, `infos` must be a non-null pointer to writable storage
/// for at least `max_length` `KeyValuePair` values.
///
/// # Return
/// Returns the number of entries written, or `-1` on failure. On failure, call
/// `get_error_msg` on the same thread to retrieve details.
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
pub unsafe extern "C" fn list_instance(infos: *mut KeyValuePair, max_length: usize) -> c_int {
unsafe { instance_api::list_instance(infos, max_length) }
}
/// Collect running network instance information.
///
/// Writes up to `max_length` entries into `infos`. Each returned key is the
/// instance name and each returned value is a JSON string containing that
/// instance's running information. Returned key/value strings are allocated by
/// this library and must be released with `free_string`.
///
/// This API fails if called from a config-server event callback.
///
/// # Safety
/// If `max_length > 0`, `infos` must be a non-null pointer to writable storage
/// for at least `max_length` `KeyValuePair` values.
///
/// # Return
/// Returns the number of entries written, or `-1` on failure. On failure, call
/// `get_error_msg` on the same thread to retrieve details.
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
pub unsafe extern "C" fn collect_network_infos(
infos: *mut KeyValuePair,
max_length: usize,
) -> std::ffi::c_int {
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
) -> c_int {
unsafe { instance_api::collect_network_infos(infos, max_length) }
}
#[cfg(test)]
mod tests {
use super::*;
/// Attach a TUN file descriptor to a named network instance.
///
/// The instance must already have been registered in the FFI name cache by
/// `run_network_instance` or by a managed config-server remote start event.
///
/// # Safety
/// `inst_name` must be a non-null pointer to a null-terminated UTF-8 string.
/// `fd` must be a valid TUN file descriptor owned by the caller.
///
/// # Return
/// Returns `0` if the descriptor is accepted by the instance, or `-1` on
/// failure.
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
pub unsafe extern "C" fn set_tun_fd(inst_name: *const c_char, fd: c_int) -> c_int {
unsafe { instance_api::set_tun_fd(inst_name, fd) }
}
#[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);
}
/// Call an exposed EasyTier RPC method using protobuf JSON.
///
/// This generic bridge intentionally excludes instance lifecycle management
/// RPCs. Use the dedicated FFI APIs for starting, retaining, deleting, and
/// collecting instances. `payload_json` must contain the protobuf JSON request,
/// including any `instance` selector required by the target RPC.
///
/// `domain_name` may be null or empty. It is only used by
/// `api.instance.TcpProxyRpcService`; null or empty defaults to `tcp`, and the
/// only accepted explicit values are `tcp`, `kcp_src`, `kcp_dst`, `quic_src`,
/// and `quic_dst`.
///
/// On success, writes a newly allocated JSON response string to
/// `out_response_json`. The caller must release it with `free_string`.
///
/// This API fails if called from a config-server event callback.
///
/// # Safety
/// `service_name`, `method_name`, `payload_json`, and `out_response_json` must
/// be non-null. String pointers must point to null-terminated UTF-8 strings.
/// `domain_name` may be null.
///
/// # Return
/// Returns `0` on success, or `-1` on failure. On failure, call
/// `get_error_msg` on the same thread to retrieve details.
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
pub unsafe extern "C" 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 {
unsafe {
json_rpc::call_json_rpc(
service_name,
method_name,
domain_name,
payload_json,
out_response_json,
)
}
}
// ===== Config Server Client API =====
/// Start the managed config-server client.
///
/// The client reuses EasyTier's web-client path and applies remote config
/// changes through the shared `NativeInstanceManager`. Successful remote run
/// and delete operations are delivered to `callback` as JSON event strings, one
/// callback per affected instance. The event string is valid only for the
/// duration of the callback; callers must copy it if they need to keep it.
///
/// The config-server client is mutually exclusive with the FFI data plane. If a
/// data-plane handle exists or is being created, this function returns `-1`.
///
/// # Safety
/// `config_server_url` and `machine_id` must be non-null pointers to
/// null-terminated UTF-8 strings. `hostname` may be null; when non-null it must
/// also point to a null-terminated UTF-8 string. `user_data` is passed back to
/// `callback` unchanged and must remain valid for the callback's expectations.
///
/// # Return
/// Returns `0` after the client starts successfully, or `-1` on failure. On
/// failure, call `get_error_msg` on the same thread to retrieve details.
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
pub unsafe extern "C" 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 {
unsafe {
config_server::start_config_server_client(
config_server_url,
hostname,
machine_id,
secure_mode,
callback,
user_data,
)
}
}
/// Stop the managed config-server client.
///
/// This stops the client, removes instances tracked as remote config-server
/// instances, waits for in-flight callback delivery when safe to do so, and
/// releases the config-server/data-plane mutual exclusion state.
///
/// # Return
/// Returns `0` if no client exists or if the active client is stopped
/// successfully. Returns `-1` on failure. On failure, call `get_error_msg` on
/// the same thread to retrieve details.
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
pub extern "C" fn stop_config_server_client() -> c_int {
config_server::stop_config_server_client()
}
/// Report whether the managed config-server client is currently connected.
///
/// # Return
/// Returns `1` when a client exists and reports connected, otherwise returns
/// `0`.
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
pub extern "C" fn is_config_server_client_connected() -> c_int {
config_server::is_config_server_client_connected()
}
// ===== Data Plane API =====
#[cfg(feature = "ffi-dataplane")]
pub use data_plane::{
DATA_PLANE_DEADLINE_READ, DATA_PLANE_DEADLINE_WRITE, data_plane_completion_drain,
data_plane_completion_wait, data_plane_operation_cancel, data_plane_operation_free,
data_plane_resource_close, data_plane_resource_deadline_set, data_plane_result_size,
data_plane_session_close, data_plane_session_open, data_plane_tcp_accept_result_take,
data_plane_tcp_accept_submit, data_plane_tcp_bind_result_take, data_plane_tcp_bind_submit,
data_plane_tcp_connect_result_take, data_plane_tcp_connect_submit,
data_plane_tcp_read_result_take, data_plane_tcp_read_submit, data_plane_tcp_write_result_take,
data_plane_tcp_write_submit, data_plane_udp_bind_result_take, data_plane_udp_bind_submit,
data_plane_udp_receive_result_take, data_plane_udp_receive_submit,
data_plane_udp_send_result_take, data_plane_udp_send_submit,
};
// ===== Shared FFI Helper API =====
/// Return the last FFI error message.
///
/// API failures are stored in a thread-local buffer, so call this on the same
/// thread that received a negative status or another documented failure
/// sentinel. Config-server
/// callback delivery failures may happen on a runtime thread; those are stored
/// globally and are included here so direct FFI callers can still retrieve the
/// last callback error. If there is no error message, this writes a null pointer
/// to `out`.
///
/// The returned string is allocated by this library and must be released with
/// `free_string`.
///
/// # Safety
/// `out` must be a non-null pointer to writable storage for one C string
/// pointer.
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
pub unsafe extern "C" fn get_error_msg(out: *mut *const c_char) {
unsafe { error::get_error_msg(out) }
}
/// Release a C string allocated by this library.
///
/// Use this for strings returned through `get_error_msg`,
/// `collect_network_infos`, and data-plane address output parameters. Passing a
/// null pointer is allowed and has no effect.
#[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
pub extern "C" fn free_string(s: *const c_char) {
error::free_string(s)
}
@@ -0,0 +1,66 @@
use std::sync::Arc;
use easytier::instance::factory::{
NativeInstanceManager, NativeProcessManagement, native_instance_manager_with_runtime,
native_process_management,
};
use tokio::runtime::{Builder, Runtime};
struct FfiOwnedInstanceHooks;
#[async_trait::async_trait]
impl easytier_core::management::InstanceMutationHooks for FfiOwnedInstanceHooks {
async fn post_remove_network_instances(
&self,
instance_ids: &[uuid::Uuid],
) -> Result<(), String> {
crate::config_server::remove_config_server_tracked_instance_ids(instance_ids);
crate::data_plane::remove_data_plane_sessions_by_instance_ids(instance_ids);
Ok(())
}
}
pub(crate) struct FfiContext {
pub(crate) runtime: Runtime,
pub(crate) manager: Arc<NativeInstanceManager>,
pub(crate) process_management: NativeProcessManagement,
}
impl FfiContext {
fn new() -> Self {
let runtime = Builder::new_multi_thread()
.enable_all()
.build()
.expect("tokio runtime for easytier-ffi");
let manager = Arc::new(native_instance_manager_with_runtime(
runtime.handle().clone(),
));
let process_management =
native_process_management(manager.clone(), Arc::new(FfiOwnedInstanceHooks));
Self {
runtime,
manager,
process_management,
}
}
}
static FFI_CONTEXT: once_cell::sync::Lazy<FfiContext> = once_cell::sync::Lazy::new(FfiContext::new);
pub(crate) fn ffi_context() -> &'static FfiContext {
&FFI_CONTEXT
}
pub(crate) fn resolve_instance_id_by_name(inst_name: &str) -> Result<Option<uuid::Uuid>, String> {
easytier_core::management::resolve_optional_instance_by_name(
ffi_context().manager.as_ref(),
inst_name,
)
.map(|instance| instance.map(|instance| instance.instance_id()))
.map_err(|error| error.to_string())
}
#[cfg(test)]
pub(crate) fn find_instance_id_by_name(inst_name: &str) -> Option<uuid::Uuid> {
resolve_instance_id_by_name(inst_name).ok().flatten()
}
@@ -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)
}
+746
View File
@@ -0,0 +1,746 @@
use crate::{
config_server::{
ConfigServerCallbackScope, ManagedConfigServerClientHooks, set_active_for_test,
},
state::{ffi_context, find_instance_id_by_name},
*,
};
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_int, 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());
ffi_context()
.manager
.run_network_instance(cfg, ConfigFileControl::STATIC_CONFIG)
.unwrap();
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]);
ffi_context()
.runtime
.block_on(
ffi_context()
.manager
.delete_network_instances([instance_id]),
)
.unwrap();
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();
ffi_context()
.manager
.run_network_instance(cfg, 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().clone();
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());
ffi_context()
.manager
.delete_network_instances([instance_id])
.await
.unwrap();
}
#[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();
ffi_context()
.manager
.run_network_instance(cfg, 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().clone();
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()])
);
ffi_context()
.manager
.delete_network_instances([instance_id_1, instance_id_2])
.await
.unwrap();
}
#[tokio::test]
async fn config_server_hooks_ignore_untracked_instance_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();
hooks
.post_remove_network_instances(&[local_id])
.await
.unwrap();
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();
let existing_cfg = TomlConfigLoader::default();
existing_cfg.set_inst_name(inst_name.clone());
existing_cfg.set_id(existing_id);
ffi_context()
.manager
.run_network_instance(existing_cfg, ConfigFileControl::STATIC_CONFIG)
.unwrap();
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!(find_instance_id_by_name(&inst_name), Some(existing_id));
ffi_context()
.manager
.delete_network_instances([existing_id])
.await
.unwrap();
}
#[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);
for (id, name) in [
(overwritten_id, old_name.clone()),
(duplicate_id, duplicate_name.clone()),
] {
let cfg = TomlConfigLoader::default();
cfg.set_id(id);
cfg.set_inst_name(name);
ffi_context()
.manager
.run_network_instance(cfg, ConfigFileControl::STATIC_CONFIG)
.unwrap();
}
ffi_context()
.manager
.delete_network_instances([overwritten_id])
.await
.unwrap();
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!(find_instance_id_by_name(&old_name).is_none());
assert_eq!(
find_instance_id_by_name(&duplicate_name),
Some(duplicate_id)
);
assert_eq!(events.lock().unwrap().len(), 1);
ffi_context()
.manager
.delete_network_instances([duplicate_id])
.await
.unwrap();
}
#[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);
let cfg = TomlConfigLoader::default();
cfg.set_inst_name(inst_name.clone());
cfg.set_id(instance_id);
ffi_context()
.manager
.run_network_instance(cfg.clone(), ConfigFileControl::STATIC_CONFIG)
.unwrap();
ffi_context()
.manager
.delete_network_instances([instance_id])
.await
.unwrap();
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!(find_instance_id_by_name(&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();
ffi_context()
.manager
.run_network_instance(cfg, ConfigFileControl::STATIC_CONFIG)
.unwrap();
ffi_context()
.manager
.delete_network_instances([instance_id])
.await
.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());
ffi_context()
.manager
.run_network_instance(cfg, ConfigFileControl::STATIC_CONFIG)
.unwrap();
assert_eq!(find_instance_id_by_name(&inst_name), Some(instance_id));
ffi_context()
.runtime
.block_on(
ffi_context()
.manager
.delete_network_instances([instance_id]),
)
.unwrap();
}
#[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());
ffi_context()
.manager
.run_network_instance(cfg, ConfigFileControl::STATIC_CONFIG)
.unwrap();
}
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());
ffi_context()
.runtime
.block_on(ffi_context().manager.delete_network_instances([keep_id]))
.unwrap();
}
#[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_process_management_uses_manager_mutation_lock() {
let manager_guard = ffi_context().manager.mutation_lock().blocking_lock_owned();
let (done_tx, done_rx) = mpsc::channel();
let waiter = std::thread::spawn(move || {
ffi_context()
.runtime
.block_on(
ffi_context()
.process_management
.delete_owned_network_instances(Vec::new()),
)
.unwrap();
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_reject_late_runs_for_core_rollback() {
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();
assert!(
hooks
.post_run_network_instance(&Uuid::new_v4())
.await
.is_err()
);
assert!(hooks.tracked_instance_ids().is_empty());
assert!(events.lock().unwrap().is_empty());
}
#[test]
fn delete_network_instance_rejects_an_ambiguous_name() {
let duplicate_name = format!("duplicate-{}", Uuid::new_v4());
let instance_ids = [Uuid::new_v4(), Uuid::new_v4()];
for instance_id in instance_ids {
let config = TomlConfigLoader::default();
config.set_id(instance_id);
config.set_inst_name(duplicate_name.clone());
ffi_context()
.manager
.run_network_instance(config, ConfigFileControl::STATIC_CONFIG)
.unwrap();
}
let duplicate_name = CString::new(duplicate_name).unwrap();
let names = [duplicate_name.as_ptr()];
assert_eq!(
unsafe { delete_network_instance(names.as_ptr(), names.len()) },
-1
);
assert!(take_last_error().unwrap().contains("2 instances match"));
assert!(
instance_ids
.iter()
.all(|id| ffi_context().manager.instance(*id).is_some())
);
ffi_context()
.runtime
.block_on(
ffi_context()
.process_management
.delete_owned_network_instances(instance_ids.to_vec()),
)
.unwrap();
}
#[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")]
{
let mut session = 0;
assert_eq!(
unsafe { data_plane_session_open(std::ptr::null(), &mut session) },
-(easytier_core::gateway::DataPlaneErrorKind::Io as c_int)
);
assert_eq!(session, 0);
}
}
#[cfg(feature = "ffi-dataplane")]
#[test]
fn active_config_server_rejects_data_plane() {
set_active_for_test(true);
let name = CString::new("missing").unwrap();
let mut session = 0;
assert_eq!(
unsafe { data_plane_session_open(name.as_ptr(), &mut session) },
-(easytier_core::gateway::DataPlaneErrorKind::Io as c_int)
);
assert_eq!(session, 0);
set_active_for_test(false);
}
#[cfg(feature = "ffi-dataplane")]
#[test]
fn data_plane_invalid_handle_errors_are_stable() {
let closed = -(easytier_core::gateway::DataPlaneErrorKind::HandleClosed as c_int);
assert_eq!(data_plane_completion_wait(u64::MAX, 0), closed);
assert_eq!(data_plane_operation_cancel(u64::MAX, 1), closed);
assert_eq!(data_plane_operation_free(u64::MAX, 1), closed);
assert_eq!(data_plane_resource_close(u64::MAX, 1), closed);
assert_eq!(
data_plane_resource_deadline_set(u64::MAX, 1, DATA_PLANE_DEADLINE_READ, 0),
closed
);
}
@@ -0,0 +1,30 @@
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)>;
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct DataPlaneSocketAddr {
/// `4` for IPv4. Other families are reserved for later ABI versions.
pub family: u16,
/// Native-endian port number.
pub port: u16,
/// Network-order address bytes. IPv4 uses the first four bytes.
pub address: [u8; 16],
}
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct DataPlaneCompletion {
pub operation_id: u64,
pub operation_kind: u16,
/// `0` for success, otherwise a stable `DataPlaneErrorKind` value.
pub status: u16,
}
@@ -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.3
version=v2.6.4
versionCode=1
author=EasyTier
description=easytier magisk module @EasyTier(https://github.com/EasyTier/EasyTier)
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "easytier-mini"
description = "Minimal native EasyTier node with TCP/UDP tunnels, TUN and UDP hole punching."
version = "2.6.4"
edition.workspace = true
rust-version.workspace = true
license-file = "../../LICENSE"
build = "build.rs"
[dependencies]
anyhow = "1.0"
easytier = { path = "../../easytier", version = "2.6.4", default-features = false, features = [
"aes-gcm",
"dhcp-ipv4",
"logging",
"proxy-cidr-monitor",
"smoltcp",
"tun",
"web-client",
] }
tokio = { version = "1", default-features = false, features = ["macros", "rt", "signal"] }
+113
View File
@@ -0,0 +1,113 @@
# easytier-mini
`easytier-mini` is a native EasyTier POC binary. It shares EasyTier's TOML
configuration model, peer protocol, TCP/UDP tunnel implementations, TUN,
dynamic IPv4 allocation, the smoltcp userspace path and STUN/UDP hole-punching
core with the full binary. It includes AES-GCM so its default encryption
setting interoperates with the full binary's default configuration.
Build it with:
```sh
cargo build --release -p easytier-mini
```
For the static size target used by this POC:
```sh
cargo build --profile mini --target x86_64-unknown-linux-musl -p easytier-mini
```
MIPS targets use the repository's existing musl-cross toolchains. The helper
builds the standard library for size, applies immediate-abort only to the mini
MIPS target graph, and can build either or both byte orders:
```sh
./easytier-contrib/easytier-mini/build-mips.sh all
./easytier-contrib/easytier-mini/build-mips.sh mips
./easytier-contrib/easytier-mini/build-mips.sh mipsel
```
The `mini` profile derives from `release` and applies `opt-level=z` to the
entire compact binary dependency graph. Full EasyTier release builds retain
their normal `opt-level=3` profile. The musl builds use a mini-only static
linker policy to stay below 5,000,000 bytes on x86-64 and 5,500,000 bytes on
MIPS without UPX or another executable compressor. The compact x86-64 linker
policy retains static PIE, packs relative relocations and folds identical code.
MIPS builds omit standard-library backtrace support and use immediate abort;
normal workspace MIPS builds are not affected. Compact linker policies omit
unwind tables.
Start it with a normal EasyTier TOML file:
```sh
easytier-mini --config mini.toml
```
`-c` is accepted as the short form of `--config`.
Start it as an EasyTier Web managed node with a complete config-server URL:
```sh
easytier-mini --config-server udp://config-server.easytier.cn:22020/TOKEN
```
`--machine-id`, `--hostname`, and `--secure-mode` match the full client's Web
identity and transport options. `--config` and `--config-server` may be used
together: the local instance remains static while Web-owned instances are
created, updated, retained, and deleted independently.
The node also exposes the native EasyTier management RPC protocol on
`127.0.0.1:15888`, so the full `easytier-cli` can inspect it:
```sh
easytier-cli node info
easytier-cli peer
easytier-cli route
easytier-cli connector list
```
For example:
```toml
instance_name = "mini"
ipv4 = "10.147.0.2"
listeners = ["tcp://0.0.0.0:11010", "udp://0.0.0.0:11010"]
[network_identity]
network_name = "mini-poc"
network_secret = "change-me"
[[peer]]
uri = "tcp://example.net:11010"
```
Local TOML and Web configuration both retain the complete authoritative model.
The compact runtime silently omits unsupported capabilities while normalizing
that model into live runtime state. EasyTier Web therefore sees every accepted
configuration value unchanged and its consistency checks converge. This also
applies to hot patches: for example, a port-forward patch remains visible to
the controller while no port-forward service starts in mini. ChaCha20 falls
back to AES-GCM rather than plaintext.
The compact runtime supports `tcp://` and `udp://` listener, mapped-listener
and peer URLs. `no_tun = true` runs through smoltcp without an OS TUN device,
and `dhcp = true` allocates the virtual IPv4 address dynamically.
The mini feature set keeps STUN collection, UDP hole punching, Web heartbeats,
Web instance lifecycle management and the config hot-patch RPC. It omits TCP
hole punching, endpoint discovery (`http://`, `https://`, `txt://` and
`srv://` peers), protobuf reflection, logger control and the rest of the full
management surface. Unsupported connector URLs are accepted as no-ops. Its
local RPC surface remains read-only for node, peer, route and connector
queries. OSPF route messages keep their original protobuf wire data, so fields
added by future EasyTier versions are forwarded without requiring
`prost-reflect`.
For size, this POC reads one file directly and does not support configuration
from stdin or `${VAR}` expansion. It omits the process-management event journal,
while the console logger still reports runtime events such as peer, connection,
listener, TUN and DHCP changes. The RPC address is currently fixed, so only one
mini process can use the default portal on a host. The x86-64 musl POC cannot
provide reliable stack backtraces because its release binary has no unwind
tables.
+61
View File
@@ -0,0 +1,61 @@
#!/bin/sh
set -eu
# Cargo invokes this same file as a rustc wrapper during compact MIPS builds.
# Applying immediate-abort here keeps the size policy scoped to easytier-mini;
# normal MIPS builds elsewhere in the workspace retain their panic behavior.
if [ "${EASYTIER_MINI_MIPS_RUSTC_WRAPPER:-}" = "1" ]; then
mini_rustc=$1
shift
for mini_rustc_arg in "$@"; do
case "$mini_rustc_arg" in
mips-unknown-linux-musl|mipsel-unknown-linux-musl)
exec "$mini_rustc" "$@" \
-Zunstable-options \
-Cpanic=immediate-abort
;;
esac
done
exec "$mini_rustc" "$@"
fi
mini_script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
mini_repo_dir=$(CDPATH= cd -- "$mini_script_dir/../.." && pwd)
mini_requested_target=${1:-all}
cd "$mini_repo_dir"
build_mips_target() {
mini_target=$1
mini_toolchain=$2
PATH="$mini_repo_dir/musl_gcc/$mini_toolchain/bin:$PATH" \
EASYTIER_MINI_MIPS_RUSTC_WRAPPER=1 \
RUSTC_BOOTSTRAP=1 \
RUSTC_WRAPPER="$mini_script_dir/build-mips.sh" \
cargo build \
--manifest-path "$mini_repo_dir/Cargo.toml" \
--profile mini \
--target "$mini_target" \
-Z build-std=std \
-Z build-std-features=optimize_for_size \
-p easytier-mini
}
case "$mini_requested_target" in
all)
build_mips_target mips-unknown-linux-musl mips-unknown-linux-muslsf
build_mips_target mipsel-unknown-linux-musl mipsel-unknown-linux-muslsf
;;
mips|mips-unknown-linux-musl)
build_mips_target mips-unknown-linux-musl mips-unknown-linux-muslsf
;;
mipsel|mipsel-unknown-linux-musl)
build_mips_target mipsel-unknown-linux-musl mipsel-unknown-linux-muslsf
;;
-h|--help)
echo "usage: $0 [all|mips|mipsel]"
;;
*)
echo "unsupported MIPS target: $mini_requested_target" >&2
exit 2
;;
esac
+32
View File
@@ -0,0 +1,32 @@
use std::env;
use std::path::PathBuf;
fn main() {
let target = env::var("TARGET").unwrap_or_default();
let profile = env::var("PROFILE").unwrap_or_default();
if !matches!(profile.as_str(), "release" | "mini")
|| !matches!(
target.as_str(),
"x86_64-unknown-linux-musl" | "mips-unknown-linux-musl" | "mipsel-unknown-linux-musl"
)
{
return;
}
let script =
PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("easytier-mini-musl.ld");
println!("cargo:rerun-if-changed={}", script.display());
// The release-derived mini profile already aborts panics. Keep the compact
// binary's linker policy local so full EasyTier musl builds retain their
// normal PIE/unwind settings.
println!("cargo:rustc-link-arg-bin=easytier-mini=-Wl,--build-id=none");
if target == "x86_64-unknown-linux-musl" {
println!("cargo:rustc-link-arg-bin=easytier-mini=-Wl,--pack-dyn-relocs=relr");
println!("cargo:rustc-link-arg-bin=easytier-mini=-Wl,--icf=all");
}
println!("cargo:rustc-link-arg-bin=easytier-mini=-Wl,--no-eh-frame-hdr");
println!(
"cargo:rustc-link-arg-bin=easytier-mini=-Wl,-T,{}",
script.display()
);
}
@@ -0,0 +1,14 @@
SECTIONS
{
.eh_frame :
{
KEEP(*crtbegin.o(.eh_frame))
KEEP(*crtend.o(.eh_frame))
}
/DISCARD/ :
{
*(EXCLUDE_FILE (*crtbegin.o *crtend.o) .eh_frame)
*(.eh_frame_hdr)
}
}
INSERT AFTER .data;
+264
View File
@@ -0,0 +1,264 @@
use std::{ffi::OsString, path::PathBuf, sync::Arc};
use anyhow::Context as _;
use easytier::common::MachineIdOptions;
use easytier::{
common::config::{ConfigFileControl, load_toml_config_from_path},
instance::factory::native_compact_instance_manager_with_runtime,
rpc_service::ReadOnlyApiRpcServer,
web_client::{WebClientHooks, parse_config_server_endpoint, run_web_client},
};
enum Command {
Run(RunOptions),
Exit,
}
#[derive(Debug, Default, PartialEq, Eq)]
struct RunOptions {
config: Option<PathBuf>,
config_server: Option<String>,
machine_id: Option<String>,
hostname: Option<String>,
secure_mode: bool,
}
const USAGE: &str = "usage: easytier-mini [--config <FILE>] [--config-server <URL>] \
[--machine-id <ID>] [--hostname <NAME>] [--secure-mode]";
fn required_value(
args: &mut impl Iterator<Item = OsString>,
option: &str,
) -> anyhow::Result<OsString> {
args.next()
.with_context(|| format!("{option} requires a value"))
}
fn parse_args(mut args: impl Iterator<Item = OsString>) -> anyhow::Result<Command> {
let mut options = RunOptions::default();
while let Some(arg) = args.next() {
if arg == "-h" || arg == "--help" {
println!(
"easytier-mini {}\n\nUsage: {USAGE}",
env!("CARGO_PKG_VERSION")
);
return Ok(Command::Exit);
}
if arg == "-V" || arg == "--version" {
println!("easytier-mini {}", env!("CARGO_PKG_VERSION"));
return Ok(Command::Exit);
}
if arg == "-c" || arg == "--config" {
if options.config.is_some() {
anyhow::bail!("--config may only be specified once");
}
options.config = Some(PathBuf::from(required_value(&mut args, "--config")?));
continue;
}
if arg == "-w" || arg == "--config-server" {
if options.config_server.is_some() {
anyhow::bail!("--config-server may only be specified once");
}
options.config_server = Some(
required_value(&mut args, "--config-server")?
.into_string()
.map_err(|_| anyhow::anyhow!("--config-server must be valid UTF-8"))?,
);
continue;
}
if arg == "--machine-id" {
options.machine_id = Some(
required_value(&mut args, "--machine-id")?
.into_string()
.map_err(|_| anyhow::anyhow!("--machine-id must be valid UTF-8"))?,
);
continue;
}
if arg == "--hostname" {
options.hostname = Some(
required_value(&mut args, "--hostname")?
.into_string()
.map_err(|_| anyhow::anyhow!("--hostname must be valid UTF-8"))?,
);
continue;
}
if arg == "--secure-mode" {
options.secure_mode = true;
continue;
}
anyhow::bail!("unknown argument {arg:?}; {USAGE}");
}
if options.config.is_none() && options.config_server.is_none() {
anyhow::bail!("either --config or --config-server is required; {USAGE}");
}
Ok(Command::Run(options))
}
fn require_tcp_or_udp(scheme: &str, source: &str) -> anyhow::Result<()> {
match scheme {
"tcp" | "udp" => Ok(()),
scheme => anyhow::bail!(
"{source} uses unsupported tunnel scheme {scheme:?}; easytier-mini supports only tcp:// and udp://"
),
}
}
fn validate_config_server(config_server: &str) -> anyhow::Result<()> {
let endpoint = parse_config_server_endpoint(config_server)?;
require_tcp_or_udp(endpoint.connect_url().scheme(), "config server")
}
struct MiniWebClientHooks;
impl WebClientHooks for MiniWebClientHooks {
fn manages_remote_config_instances(&self) -> bool {
true
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> anyhow::Result<()> {
let Command::Run(options) = parse_args(std::env::args_os().skip(1))? else {
return Ok(());
};
easytier::common::log::init_console()?;
let local_config = options
.config
.as_ref()
.map(|config_path| {
load_toml_config_from_path(config_path)
.with_context(|| format!("failed to load {}", config_path.display()))
})
.transpose()?;
if let Some(config_server) = options.config_server.as_deref() {
validate_config_server(config_server)?;
}
let instances = Arc::new(native_compact_instance_manager_with_runtime(
tokio::runtime::Handle::current(),
));
let local_instance_id = local_config
.map(|config| instances.run_network_instance(config, ConfigFileControl::STATIC_CONFIG))
.transpose()?;
let _web_client = if let Some(config_server) = options.config_server.as_deref() {
Some(
run_web_client(
config_server,
MachineIdOptions {
explicit_machine_id: options.machine_id,
state_dir: None,
},
options.hostname,
options.secure_mode,
instances.clone(),
Some(Arc::new(MiniWebClientHooks)),
)
.await?,
)
} else {
None
};
let _rpc_server =
ReadOnlyApiRpcServer::new(Some("127.0.0.1:15888".to_owned()), None, instances.clone())?
.serve()
.await?;
eprintln!(
"easytier-mini started: local={local_instance_id:?}, web={}; RPC: 127.0.0.1:15888",
options.config_server.is_some()
);
let stopped_unexpectedly = tokio::select! {
signal = tokio::signal::ctrl_c() => {
signal.context("failed to listen for Ctrl-C")?;
false
},
_ = instances.wait() => true,
};
for instance in instances.instances() {
instance.stop().await;
}
if stopped_unexpectedly {
anyhow::bail!("EasyTier instance stopped unexpectedly");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use easytier::common::config::{ConfigLoader as _, TomlConfigLoader};
#[test]
fn parses_minimal_config_argument() {
let Command::Run(options) =
parse_args([OsString::from("--config"), OsString::from("mini.toml")].into_iter())
.unwrap()
else {
panic!("expected run command");
};
assert_eq!(options.config, Some(PathBuf::from("mini.toml")));
}
#[test]
fn parses_web_client_arguments_without_a_local_config() {
let Command::Run(options) = parse_args(
[
OsString::from("--config-server"),
OsString::from("token"),
OsString::from("--machine-id"),
OsString::from("machine"),
OsString::from("--hostname"),
OsString::from("mini"),
OsString::from("--secure-mode"),
]
.into_iter(),
)
.unwrap() else {
panic!("expected run command");
};
assert_eq!(options.config_server.as_deref(), Some("token"));
assert_eq!(options.machine_id.as_deref(), Some("machine"));
assert_eq!(options.hostname.as_deref(), Some("mini"));
assert!(options.secure_mode);
}
#[test]
fn rejects_unknown_arguments() {
let result = parse_args([OsString::from("extra")].into_iter());
assert!(result.is_err());
}
#[test]
fn accepts_tcp_udp_config_server() {
assert!(validate_config_server("udp://127.0.0.1:22020/token").is_ok());
assert!(validate_config_server("quic://127.0.0.1:22020/token").is_err());
}
#[tokio::test]
async fn compact_factory_accepts_unsupported_config_without_changing_it() {
let config = TomlConfigLoader::new_from_str(
r#"
dhcp = true
listeners = ["quic://127.0.0.1:11010"]
proxy_network = [{ cidr = "10.20.0.0/16" }]
[flags]
encryption_algorithm = "chacha20"
data_compress_algo = "Zstd"
"#,
)
.unwrap();
config.get_id();
let before = config.dump();
let manager =
native_compact_instance_manager_with_runtime(tokio::runtime::Handle::current());
let instance = manager.create(config, ()).unwrap();
assert_eq!(instance.toml_config().unwrap().dump(), before);
}
}
+858 -487
View File
File diff suppressed because it is too large Load Diff
+19 -1
View File
@@ -7,7 +7,19 @@ edition = "2024"
crate-type=["cdylib"]
[dependencies]
ohos-hilog-binding = {version = "*", features = ["redirect"]}
anyhow = "1.0"
async-trait = "0.1"
base64 = "0.22"
bytes = "1.5"
easytier-core = { path = "../../easytier-core", default-features = false }
easytier-proto = { path = "../../easytier-proto", default-features = false, features = [
"api",
"core",
"json-rpc",
] }
flate2 = "1.1"
futures = "0.3"
gethostname = "1.1"
easytier = { path = "../../easytier" }
napi-derive-ohos = "1.1"
napi-ohos = { version = "1.1", default-features = false, features = [
@@ -26,10 +38,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,49 @@
use crate::config::types::stored_config::{ExportTomlResult, StoredConfigRecord};
use easytier::common::config::NetworkConfigExt;
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,43 @@
use easytier::common::config::NetworkConfigExt;
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 = ASYNC_RUNTIME
.block_on(INSTANCE_MANAGER.delete_network_instances([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.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
.attach_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,580 @@
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::instance::factory::subscribe_native_instance_event;
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, SystemTime, UNIX_EPOCH};
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 {
sampled_at_ms: i64,
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.instances() {
let instance_id = instance.instance_id().to_string();
active_instance_ids.insert(instance_id.clone());
if !receivers.contains_key(&instance_id)
&& let Some(receiver) = subscribe_native_instance_event(&instance)
{
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(sampled_at_ms: i64) -> TrafficStatsPayload {
let running_instances = INSTANCE_MANAGER
.instances()
.into_iter()
.filter(|instance| instance.is_ready())
.collect::<Vec<_>>();
let instances = ASYNC_RUNTIME.block_on(async {
let mut instances = Vec::new();
for instance in running_instances {
let instance_id = instance.instance_id().to_string();
let peers = instance.peer_snapshots().await;
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 {
sampled_at_ms,
instances,
}
}
fn unix_time_millis() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis().min(i64::MAX as u128) as i64)
.unwrap_or_default()
}
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(unix_time_millis())) {
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
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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),
}
}
@@ -10,9 +10,8 @@ use easytier::{
common::config::{
ConfigFileControl, ConfigLoader, NetworkIdentity, PeerConfig, TomlConfigLoader,
},
instance_manager::NetworkInstanceManager,
instance::factory::{NativeInstanceManager, native_instance_manager},
};
use guarden::defer;
use serde::{Deserialize, Serialize};
use sqlx::any;
use tokio_util::task::AbortOnDropHandle;
@@ -28,6 +27,32 @@ pub struct HealthCheckOneNode {
node_id: String,
}
struct InstanceCleanupGuard {
manager: Arc<NativeInstanceManager>,
instance_id: Option<uuid::Uuid>,
runtime: tokio::runtime::Handle,
}
impl InstanceCleanupGuard {
async fn cleanup(mut self) {
let instance_id = self.instance_id.unwrap();
let _ = self.manager.delete_network_instances([instance_id]).await;
self.instance_id = None;
}
}
impl Drop for InstanceCleanupGuard {
fn drop(&mut self) {
let Some(instance_id) = self.instance_id.take() else {
return;
};
let manager = self.manager.clone();
self.runtime.spawn(async move {
let _ = manager.delete_network_instances([instance_id]).await;
});
}
}
const HEALTH_CHECK_RING_GRANULARITY_SEC: usize = 60 * 15; // 15分钟
const HEALTH_CHECK_RING_MAX_DURATION_SEC: usize = 60 * 60 * 24; // 最多一天
@@ -238,7 +263,7 @@ impl HealthyMemRecord {
pub struct HealthChecker {
db: Db,
instance_mgr: Arc<NetworkInstanceManager>,
instance_mgr: Arc<NativeInstanceManager>,
inst_id_map: DashMap<i32, uuid::Uuid>,
node_tasks: DashMap<i32, AbortOnDropHandle<()>>,
node_records: Arc<DashMap<i32, HealthyMemRecord>>,
@@ -247,7 +272,7 @@ pub struct HealthChecker {
impl HealthChecker {
pub fn new(db: Db) -> Self {
let instance_mgr = Arc::new(NetworkInstanceManager::new());
let instance_mgr = Arc::new(native_instance_manager());
Self {
db,
instance_mgr,
@@ -387,33 +412,38 @@ impl HealthChecker {
max_time: Duration,
) -> anyhow::Result<()> {
let cfg = self.get_node_cfg_with_model(node_info, None).await?;
defer!({
let _ = self
.instance_mgr
.delete_network_instance(vec![cfg.get_id()]);
});
self.instance_mgr
.run_network_instance(cfg.clone(), false, ConfigFileControl::STATIC_CONFIG)
.run_network_instance(cfg.clone(), ConfigFileControl::STATIC_CONFIG)
.with_context(|| "failed to run network instance")?;
let cleanup = InstanceCleanupGuard {
manager: self.instance_mgr.clone(),
instance_id: Some(cfg.get_id()),
runtime: tokio::runtime::Handle::current(),
};
let now = Instant::now();
let mut err = None;
while now.elapsed() < max_time {
match Self::test_node_healthy(cfg.get_id(), self.instance_mgr.clone()).await {
Ok(_) => {
return Ok(());
}
Err(e) => {
warn!(
"test node healthy failed, node_info: {:?}, err: {}",
node_info, e
);
err = Some(e);
let result = async {
let now = Instant::now();
let mut err = None;
while now.elapsed() < max_time {
match Self::test_node_healthy(cfg.get_id(), self.instance_mgr.clone()).await {
Ok(_) => {
return Ok(());
}
Err(e) => {
warn!(
"test node healthy failed, node_info: {:?}, err: {}",
node_info, e
);
err = Some(e);
}
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
tokio::time::sleep(Duration::from_millis(100)).await;
Err(anyhow::anyhow!("test node healthy failed, err: {:?}", err))
}
Err(anyhow::anyhow!("test node healthy failed, err: {:?}", err))
.await;
cleanup.cleanup().await;
result
}
async fn get_node_cfg(
@@ -437,7 +467,7 @@ impl HealthChecker {
);
self.instance_mgr
.run_network_instance(cfg.clone(), true, ConfigFileControl::STATIC_CONFIG)
.run_network_instance(cfg.clone(), ConfigFileControl::STATIC_CONFIG)
.with_context(|| "failed to run network instance")?;
self.inst_id_map.insert(node_id, cfg.get_id());
@@ -481,7 +511,10 @@ impl HealthChecker {
pub async fn remove_node(&self, node_id: i32) -> anyhow::Result<()> {
self.node_tasks.remove(&node_id);
if let Some(inst_id) = self.inst_id_map.remove(&node_id) {
let _ = self.instance_mgr.delete_network_instance(vec![inst_id.1]);
let _ = self
.instance_mgr
.delete_network_instances([inst_id.1])
.await;
}
self.node_cfg.remove(&node_id);
// 保留内存记录,不删除,以便后续查询历史数据
@@ -495,10 +528,10 @@ impl HealthChecker {
#[instrument(err, ret, skip(instance_mgr))]
async fn test_node_healthy(
inst_id: uuid::Uuid,
instance_mgr: Arc<NetworkInstanceManager>,
instance_mgr: Arc<NativeInstanceManager>,
// return version, response time on healthy, conn_count
) -> anyhow::Result<(String, u64, u32)> {
let Some(instance) = instance_mgr.get_network_info(&inst_id).await else {
let Some(instance) = instance_mgr.network_info(inst_id).await else {
anyhow::bail!("healthy check node is not started");
};
@@ -566,7 +599,7 @@ impl HealthChecker {
async fn node_health_check_task(
node_id: i32,
inst_id: uuid::Uuid,
instance_mgr: Arc<NetworkInstanceManager>,
instance_mgr: Arc<NativeInstanceManager>,
db: Db,
node_records: Arc<DashMap<i32, HealthyMemRecord>>,
) {
+154
View File
@@ -0,0 +1,154 @@
[package]
name = "easytier-core"
description = "EasyTier OS-free control-plane core primitives."
homepage = "https://github.com/EasyTier/EasyTier"
repository = "https://github.com/EasyTier/EasyTier"
version = "2.6.4"
edition.workspace = true
rust-version.workspace = true
authors = ["kkrainbow"]
keywords = ["vpn", "p2p", "network", "easytier"]
categories = ["network-programming"]
license-file = "../LICENSE"
[lib]
crate-type = ["rlib", "cdylib"]
[package.metadata.wasm-pack.profile.release]
wasm-opt = ["-Oz", "--enable-bulk-memory", "--enable-nontrapping-float-to-int"]
[dependencies]
anyhow = "1.0"
ariadne = { version = "0.5", optional = true }
arc-swap = "1.7"
async-ringbuf = "0.3.1"
async-trait = "0.1.74"
auto_impl = "1.1.0"
base64 = "0.22"
bitflags = "2.5"
bytecodec = "0.4.15"
bytes = "1.5.0"
chrono = { version = "0.4.37", features = ["clock"] }
cidr = { version = "0.3.1", features = ["serde"] }
crossbeam = "0.8.4"
dashmap = "6.0"
derive_builder = "0.20.2"
easytier-proto = { path = "../easytier-proto", default-features = false, features = ["core"] }
futures = "0.3"
guarden = "0.2"
hmac = "0.12.1"
http-body-util = { version = "0.1", optional = true }
hyper = { version = "1", default-features = false, features = ["client", "http1"], optional = true }
hyper-util = { version = "0.1", default-features = false, features = ["tokio"], optional = true }
idna = "1.0"
atomic-shim = "0.2.0"
ordered_hash_map = "0.5.0"
parking_lot = "0.12.1"
percent-encoding = "2.3.1"
petgraph = "0.8.1"
pin-project-lite = "0.2.13"
prefix-trie = { version = "0.7.0", features = ["cidr"] }
prost = "0.14.3"
prost-types = "0.14.3"
rand = "0.8.5"
quanta = "0.12"
ring = { version = "0.17", optional = true }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"], optional = true }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1"
sha2 = "0.10.8"
smoltcp = { git = "https://github.com/smoltcp-rs/smoltcp.git", rev = "0a926767a68bc88d5512afefa7529c5ecdade4ea", optional = true, default-features = false }
stun_codec = "0.3.4"
thiserror = "1.0"
tracing = "0.1"
strum = { version = "0.27.2", features = ["derive"] }
toml = "0.8.12"
tokio = { version = "1", default-features = false, features = [
"rt",
"time",
"sync",
"macros",
"io-util",
] }
tokio-util = { version = "0.7", features = ["io", "rt"] }
tokio-rustls = { version = "0.26", default-features = false, optional = true }
url = { version = "2.5", features = ["serde"] }
wildmatch = "2.3.4"
uuid = { version = "1.5.0", features = ["v4", "fast-rng", "serde"] }
webpki-roots = { version = "0.26", optional = true }
x25519-dalek = { version = "2.0", features = ["static_secrets"] }
zerocopy = { version = "0.7.32", features = ["derive", "simd"] }
zstd = { version = "0.13", optional = true }
aes-gcm = { version = "0.10.3", optional = true }
chacha20poly1305 = { version = "0.10.1", optional = true }
openssl = { version = "0.10", optional = true, features = ["vendored"] }
[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
getrandom-02 = { package = "getrandom", version = "0.2.15", features = ["js"] }
getrandom-03 = { package = "getrandom", version = "0.3.2", features = ["wasm_js"] }
snow = { version = "0.10.0", default-features = false, features = ["default-resolver", "default-resolver-crypto"] }
uuid = { version = "1.5.0", features = ["js"] }
wasm-bindgen = "0.2"
[target.'cfg(not(all(target_arch = "wasm32", target_os = "unknown")))'.dependencies]
snow = "0.10.0"
[features]
default = ["aes-gcm", "endpoint-discovery", "extended-services", "management", "tcp-hole-punch"]
aes-gcm = ["dep:aes-gcm"]
browser-config = ["config-write", "easytier-proto/api", "easytier-proto/json-rpc"]
chacha20 = ["dep:chacha20poly1305"]
openssl-crypto = ["dep:openssl"]
ring-crypto = ["dep:ring"]
wasi-crypto-offload = ["ring-crypto"]
config-write = []
endpoint-discovery = [
"dep:http-body-util",
"dep:hyper",
"dep:hyper-util",
"dep:rustls",
"dep:tokio-rustls",
"dep:webpki-roots",
]
dhcp-ipv4 = []
public-ipv6-provider = []
vpn-portal = []
wrapped-transport = []
extended-services = [
"dhcp-ipv4",
"public-ipv6-provider",
"vpn-portal",
"wrapped-transport",
"proxy-cidr-monitor",
]
web-client = ["management-rpc", "config-write"]
management = ["web-client", "extended-services", "rich-config-errors", "easytier-proto/json-rpc"]
management-rpc = ["easytier-proto/api"]
proxy-cidr-monitor = []
rich-config-errors = ["dep:ariadne"]
tcp-hole-punch = []
proxy-packet = [
"wrapped-transport",
"dep:smoltcp",
"smoltcp/std",
"smoltcp/proto-ipv4",
"smoltcp/proto-ipv4-fragmentation",
"smoltcp/fragmentation-buffer-size-65536",
"smoltcp/assembler-max-segment-count-16",
"smoltcp/reassembly-buffer-size-65536",
"smoltcp/reassembly-buffer-count-16",
]
proxy-smoltcp-stack = [
"proxy-packet",
"smoltcp/medium-ip",
"smoltcp/socket-tcp",
"smoltcp/socket-udp",
"smoltcp/proto-ipv6",
"smoltcp/async",
]
test-utils = []
tracing-log = ["tracing/log"]
zstd = ["dep:zstd"]
[target.'cfg(not(target_os = "wasi"))'.dev-dependencies]
tokio = { version = "1", default-features = false, features = ["rt-multi-thread"] }
+166
View File
@@ -0,0 +1,166 @@
//! Portable conversion between the shared TOML model and management schema.
use easytier_proto::api::manage::{
self, NetworkConfig, NetworkingMethod, PortForwardConfig as ApiPortForwardConfig,
};
use super::toml::{ConfigLoader as _, TomlConfig};
pub fn network_config_from_toml(config: &TomlConfig) -> NetworkConfig {
let default_config = TomlConfig::default();
let mut result = NetworkConfig {
instance_id: Some(config.get_id().to_string()),
dhcp: Some(config.get_dhcp()),
..Default::default()
};
if config.get_hostname() != default_config.get_hostname() {
result.hostname = Some(config.get_hostname());
}
let network_identity = config.get_network_identity();
result.network_name = Some(network_identity.network_name);
result.network_secret = network_identity.network_secret;
if let Some(ipv4) = config.get_ipv4() {
result.virtual_ipv4 = Some(ipv4.address().to_string());
result.network_length = Some(ipv4.network_length() as i32);
}
if config.get_ipv6_public_addr_provider() != default_config.get_ipv6_public_addr_provider() {
result.ipv6_public_addr_provider = Some(config.get_ipv6_public_addr_provider());
}
if config.get_ipv6_public_addr_auto() != default_config.get_ipv6_public_addr_auto() {
result.ipv6_public_addr_auto = Some(config.get_ipv6_public_addr_auto());
}
result.ipv6_public_addr_prefix = config
.get_ipv6_public_addr_prefix()
.map(|prefix| prefix.to_string());
let peers = config.get_peers();
result.networking_method = Some(NetworkingMethod::Manual as i32);
if !peers.is_empty() {
result.peer_urls = peers.iter().map(|peer| peer.uri.to_string()).collect();
result.peers = peers
.iter()
.map(|peer| manage::NetworkPeerConfig {
uri: peer.uri.to_string(),
peer_public_key: peer.peer_public_key.clone(),
})
.collect();
}
result.listener_urls = config
.get_listeners()
.unwrap_or_default()
.iter()
.map(ToString::to_string)
.collect();
result.proxy_cidrs = config
.get_proxy_cidrs()
.iter()
.map(|proxy| match proxy.mapped_cidr {
Some(mapped) => format!("{}->{}", proxy.cidr, mapped),
None => proxy.cidr.to_string(),
})
.collect();
let port_forwards = config.get_port_forwards();
if !port_forwards.is_empty() {
result.port_forwards = port_forwards
.iter()
.map(|forward| ApiPortForwardConfig {
proto: forward.proto.clone(),
bind_ip: forward.bind_addr.ip().to_string(),
bind_port: forward.bind_addr.port() as u32,
dst_ip: forward.dst_addr.ip().to_string(),
dst_port: forward.dst_addr.port() as u32,
})
.collect();
}
if let Some(vpn_config) = config.get_vpn_portal_config() {
result.enable_vpn_portal = Some(true);
result.vpn_portal_client_network_addr =
Some(vpn_config.client_cidr.first_address().to_string());
result.vpn_portal_client_network_len = Some(vpn_config.client_cidr.network_length() as i32);
result.vpn_portal_listen_port = Some(vpn_config.wireguard_listen.port() as i32);
}
if let Some(routes) = config.get_routes()
&& !routes.is_empty()
{
result.enable_manual_routes = Some(true);
result.routes = routes.iter().map(ToString::to_string).collect();
}
let exit_nodes = config.get_exit_nodes();
if !exit_nodes.is_empty() {
result.exit_nodes = exit_nodes.iter().map(ToString::to_string).collect();
}
if let Some(socks5_portal) = config.get_socks5_portal() {
result.enable_socks5 = Some(true);
result.socks5_port = socks5_portal.port().map(|port| port as i32);
}
let mapped_listeners = config.get_mapped_listeners();
if !mapped_listeners.is_empty() {
result.mapped_listeners = mapped_listeners.iter().map(ToString::to_string).collect();
}
result.secure_mode = config.get_secure_mode();
result.credential_file = config
.get_credential_file()
.map(|path| path.to_string_lossy().into_owned());
let flags = config.get_flags();
let default_flags = default_config.get_flags();
result.latency_first = Some(flags.latency_first);
result.dev_name = Some(flags.dev_name.clone());
result.use_smoltcp = Some(flags.use_smoltcp);
result.disable_ipv6 = Some(!flags.enable_ipv6);
result.enable_kcp_proxy = Some(flags.enable_kcp_proxy);
result.disable_kcp_input = Some(flags.disable_kcp_input);
result.enable_quic_proxy = Some(flags.enable_quic_proxy);
result.disable_quic_input = Some(flags.disable_quic_input);
result.disable_p2p = Some(flags.disable_p2p);
result.p2p_only = Some(flags.p2p_only);
result.lazy_p2p = Some(flags.lazy_p2p);
result.bind_device = Some(flags.bind_device);
result.socket_mark = flags.socket_mark;
result.no_tun = Some(flags.no_tun);
result.enable_exit_node = Some(flags.enable_exit_node);
result.relay_all_peer_rpc = Some(flags.relay_all_peer_rpc);
result.need_p2p = Some(flags.need_p2p);
result.multi_thread = Some(flags.multi_thread);
result.proxy_forward_by_system = Some(flags.proxy_forward_by_system);
result.disable_encryption = Some(!flags.enable_encryption);
result.disable_tcp_hole_punching = Some(flags.disable_tcp_hole_punching);
result.disable_udp_hole_punching = Some(flags.disable_udp_hole_punching);
result.disable_upnp = Some(flags.disable_upnp);
result.disable_relay_data = Some(flags.disable_relay_data);
result.enable_udp_broadcast_relay = Some(flags.enable_udp_broadcast_relay);
result.disable_sym_hole_punching = Some(flags.disable_sym_hole_punching);
result.enable_magic_dns = Some(flags.accept_dns);
result.mtu = Some(flags.mtu as i32);
result.data_compress_algo = (flags.data_compress_algo != default_flags.data_compress_algo)
.then_some(flags.data_compress_algo);
result.encryption_algorithm = (flags.encryption_algorithm
!= default_flags.encryption_algorithm)
.then_some(flags.encryption_algorithm);
result.instance_recv_bps_limit =
(flags.instance_recv_bps_limit != u64::MAX).then_some(flags.instance_recv_bps_limit);
result.enable_private_mode = Some(flags.private_mode);
result.acl = config.get_acl();
if flags.relay_network_whitelist == "*" {
result.enable_relay_network_whitelist = Some(false);
} else {
result.enable_relay_network_whitelist = Some(true);
result.relay_network_whitelist = flags
.relay_network_whitelist
.split_whitespace()
.map(ToOwned::to_owned)
.collect();
}
result
}
+652
View File
@@ -0,0 +1,652 @@
//! Conversion between the management NetworkConfig schema and shared TOML.
use std::net::SocketAddr;
use anyhow::Context;
use easytier_proto::api::manage;
use crate::config::{
MappedListenerPolicy, normalize_secure_mode_config,
toml::{
ConfigLoader, NetworkIdentity, PeerConfig, PortForwardConfig, TomlConfigLoader,
VpnPortalConfig, gen_default_flags,
},
};
fn parse_mapped_listener_urls(mapped_listeners: &[String]) -> Result<Vec<url::Url>, anyhow::Error> {
MappedListenerPolicy::new(["tcp", "udp", "wg", "quic", "ws", "wss", "faketcp"])
.parse_urls(mapped_listeners)
}
pub fn add_proxy_network_to_config(
proxy_network: &str,
cfg: &TomlConfigLoader,
) -> Result<(), anyhow::Error> {
let parts: Vec<&str> = proxy_network.split("->").collect();
let real_cidr = parts[0]
.parse()
.with_context(|| format!("failed to parse proxy network: {}", parts[0]))?;
if parts.len() > 2 {
return Err(anyhow::anyhow!(
"invalid proxy network format: {}, support format: <real_cidr> or <real_cidr>-><mapped_cidr>, example:
10.0.0.0/24 or 10.0.0.0/24->192.168.0.0/24",
proxy_network
));
}
let mapped_cidr = if parts.len() == 2 {
Some(
parts[1]
.parse()
.with_context(|| format!("failed to parse mapped network: {}", parts[1]))?,
)
} else {
None
};
cfg.add_proxy_cidr(real_cidr, mapped_cidr)?;
Ok(())
}
pub type NetworkingMethod = easytier_proto::api::manage::NetworkingMethod;
pub type NetworkConfig = easytier_proto::api::manage::NetworkConfig;
pub trait NetworkConfigExt {
fn gen_config(&self) -> Result<TomlConfigLoader, anyhow::Error>;
fn new_from_config(config: impl ConfigLoader) -> Result<NetworkConfig, anyhow::Error>;
}
fn parse_peer(peer: &manage::NetworkPeerConfig) -> Result<Option<PeerConfig>, anyhow::Error> {
let uri = peer.uri.trim();
if uri.is_empty() {
return Ok(None);
}
Ok(Some(PeerConfig {
uri: uri
.parse()
.with_context(|| format!("failed to parse peer uri: {}", uri))?,
peer_public_key: peer.peer_public_key.clone(),
}))
}
fn parse_peers(peers: &[manage::NetworkPeerConfig]) -> Result<Vec<PeerConfig>, anyhow::Error> {
let mut ret = Vec::new();
for peer in peers {
if let Some(peer) = parse_peer(peer)? {
ret.push(peer);
}
}
Ok(ret)
}
fn parse_peer_urls(peer_urls: &[String]) -> Result<Vec<PeerConfig>, anyhow::Error> {
let mut peers = vec![];
for peer_url in peer_urls.iter() {
let peer_url = peer_url.trim();
if peer_url.is_empty() {
continue;
}
peers.push(PeerConfig {
uri: peer_url
.parse()
.with_context(|| format!("failed to parse peer uri: {}", peer_url))?,
peer_public_key: None,
});
}
Ok(peers)
}
impl NetworkConfigExt for NetworkConfig {
fn gen_config(&self) -> Result<TomlConfigLoader, anyhow::Error> {
let cfg = TomlConfigLoader::default();
cfg.set_id(
self.instance_id
.clone()
.unwrap_or(uuid::Uuid::new_v4().to_string())
.parse()
.with_context(|| format!("failed to parse instance id: {:?}", self.instance_id))?,
);
cfg.set_hostname(self.hostname.clone());
cfg.set_dhcp(self.dhcp.unwrap_or_default());
cfg.set_inst_name(self.network_name.clone().unwrap_or_default());
// The web UI does not expose credential inputs directly, but imported/saved
// NetworkConfig objects still need to preserve credential-mode instances via
// secure_mode.local_private_key + empty network_secret.
let credential_secret = if self.network_secret.is_some() {
None
} else {
self.secure_mode
.as_ref()
.and_then(|mode| mode.local_private_key.clone())
.filter(|s| !s.is_empty())
};
if credential_secret.is_some() {
cfg.set_network_identity(NetworkIdentity::new_credential(
self.network_name.clone().unwrap_or_default(),
));
} else {
cfg.set_network_identity(NetworkIdentity::new(
self.network_name.clone().unwrap_or_default(),
self.network_secret.clone().unwrap_or_default(),
));
}
if !cfg.get_dhcp() {
let virtual_ipv4 = self.virtual_ipv4.clone().unwrap_or_default();
if !virtual_ipv4.is_empty() {
let ip = format!("{}/{}", virtual_ipv4, self.network_length.unwrap_or(24))
.parse()
.with_context(|| {
format!(
"failed to parse ipv4 inet address: {}, {:?}",
virtual_ipv4, self.network_length
)
})?;
cfg.set_ipv4(Some(ip));
}
}
match NetworkingMethod::try_from(self.networking_method.unwrap_or_default())
.unwrap_or_default()
{
NetworkingMethod::PublicServer => {
let peers = parse_peers(&self.peers)?;
if peers.is_empty() {
let public_server_url = self.public_server_url.clone().unwrap_or_default();
cfg.set_peers(vec![PeerConfig {
uri: public_server_url.parse().with_context(|| {
format!("failed to parse public server uri: {}", public_server_url)
})?,
peer_public_key: None,
}]);
} else {
cfg.set_peers(peers);
}
}
NetworkingMethod::Manual => {
let mut peers = parse_peers(&self.peers)?;
if peers.is_empty() {
peers = parse_peer_urls(&self.peer_urls)?;
}
if !peers.is_empty() {
cfg.set_peers(peers);
}
}
NetworkingMethod::Standalone => {}
}
let mut listener_urls = vec![];
for listener_url in self.listener_urls.iter() {
if listener_url.is_empty() {
continue;
}
listener_urls.push(
listener_url
.parse()
.with_context(|| format!("failed to parse listener uri: {}", listener_url))?,
);
}
cfg.set_listeners(listener_urls);
for n in self.proxy_cidrs.iter() {
add_proxy_network_to_config(n, &cfg)?;
}
if !self.port_forwards.is_empty() {
cfg.set_port_forwards(
self.port_forwards
.iter()
.filter(|pf| !pf.bind_ip.is_empty() && !pf.dst_ip.is_empty())
.filter_map(|pf| {
let bind_addr =
format!("{}:{}", pf.bind_ip, pf.bind_port).parse::<SocketAddr>();
let dst_addr =
format!("{}:{}", pf.dst_ip, pf.dst_port).parse::<SocketAddr>();
match (bind_addr, dst_addr) {
(Ok(bind_addr), Ok(dst_addr)) => Some(PortForwardConfig {
bind_addr,
dst_addr,
proto: pf.proto.clone(),
}),
_ => None,
}
})
.collect::<Vec<_>>(),
);
}
if self.enable_vpn_portal.unwrap_or_default() {
let cidr = format!(
"{}/{}",
self.vpn_portal_client_network_addr
.clone()
.unwrap_or_default(),
self.vpn_portal_client_network_len.unwrap_or(24)
);
cfg.set_vpn_portal_config(VpnPortalConfig {
client_cidr: cidr
.parse()
.with_context(|| format!("failed to parse vpn portal client cidr: {}", cidr))?,
wireguard_listen: format!(
"0.0.0.0:{}",
self.vpn_portal_listen_port.unwrap_or_default()
)
.parse()
.with_context(|| {
format!(
"failed to parse vpn portal wireguard listen port. {:?}",
self.vpn_portal_listen_port
)
})?,
});
}
if self.enable_manual_routes.unwrap_or_default() {
let mut routes = Vec::<cidr::Ipv4Cidr>::with_capacity(self.routes.len());
for route in self.routes.iter() {
routes.push(
route
.parse()
.with_context(|| format!("failed to parse route: {}", route))?,
);
}
cfg.set_routes(Some(routes));
}
if !self.exit_nodes.is_empty() {
let mut exit_nodes = Vec::<std::net::IpAddr>::with_capacity(self.exit_nodes.len());
for node in self.exit_nodes.iter() {
exit_nodes.push(
node.parse()
.with_context(|| format!("failed to parse exit node: {}", node))?,
);
}
cfg.set_exit_nodes(exit_nodes);
}
if self.enable_socks5.unwrap_or_default()
&& let Some(socks5_port) = self.socks5_port
{
cfg.set_socks5_portal(Some(
format!("socks5://0.0.0.0:{}", socks5_port).parse().unwrap(),
));
}
if !self.mapped_listeners.is_empty() {
let mapped_listeners = parse_mapped_listener_urls(&self.mapped_listeners)?;
cfg.set_mapped_listeners(Some(mapped_listeners));
}
if let Some(credential_file) = self
.credential_file
.as_ref()
.filter(|path| !path.is_empty())
{
cfg.set_credential_file(Some(credential_file.into()));
}
if let Some(credential_secret) = credential_secret {
cfg.set_secure_mode(Some(normalize_secure_mode_config(
easytier_proto::common::SecureModeConfig {
enabled: true,
local_private_key: Some(credential_secret),
local_public_key: None,
},
)?));
} else {
cfg.set_secure_mode(
self.secure_mode
.clone()
.map(normalize_secure_mode_config)
.transpose()?,
);
}
let mut flags = gen_default_flags();
if let Some(latency_first) = self.latency_first {
flags.latency_first = latency_first;
}
if let Some(dev_name) = self.dev_name.clone() {
flags.dev_name = dev_name;
}
if let Some(use_smoltcp) = self.use_smoltcp {
flags.use_smoltcp = use_smoltcp;
}
if let Some(ipv6_public_addr_provider) = self.ipv6_public_addr_provider {
cfg.set_ipv6_public_addr_provider(ipv6_public_addr_provider);
}
if let Some(ipv6_public_addr_auto) = self.ipv6_public_addr_auto {
cfg.set_ipv6_public_addr_auto(ipv6_public_addr_auto);
}
if let Some(ipv6_public_addr_prefix) = self
.ipv6_public_addr_prefix
.as_ref()
.filter(|prefix| !prefix.is_empty())
{
cfg.set_ipv6_public_addr_prefix(Some(ipv6_public_addr_prefix.parse().with_context(
|| format!("failed to parse ipv6 public address prefix: {ipv6_public_addr_prefix}"),
)?));
}
if let Some(disable_ipv6) = self.disable_ipv6 {
flags.enable_ipv6 = !disable_ipv6;
}
if let Some(enable_kcp_proxy) = self.enable_kcp_proxy {
flags.enable_kcp_proxy = enable_kcp_proxy;
}
if let Some(disable_kcp_input) = self.disable_kcp_input {
flags.disable_kcp_input = disable_kcp_input;
}
if let Some(enable_quic_proxy) = self.enable_quic_proxy {
flags.enable_quic_proxy = enable_quic_proxy;
}
if let Some(disable_quic_input) = self.disable_quic_input {
flags.disable_quic_input = disable_quic_input;
}
if let Some(disable_p2p) = self.disable_p2p {
flags.disable_p2p = disable_p2p;
}
if let Some(p2p_only) = self.p2p_only {
flags.p2p_only = p2p_only;
}
if let Some(lazy_p2p) = self.lazy_p2p {
flags.lazy_p2p = lazy_p2p;
}
if let Some(bind_device) = self.bind_device {
flags.bind_device = bind_device;
}
if self.socket_mark.is_some() {
flags.socket_mark = self.socket_mark;
}
if let Some(no_tun) = self.no_tun {
flags.no_tun = no_tun;
}
if let Some(enable_exit_node) = self.enable_exit_node {
flags.enable_exit_node = enable_exit_node;
}
if let Some(relay_all_peer_rpc) = self.relay_all_peer_rpc {
flags.relay_all_peer_rpc = relay_all_peer_rpc;
}
if let Some(need_p2p) = self.need_p2p {
flags.need_p2p = need_p2p;
}
if let Some(multi_thread) = self.multi_thread {
flags.multi_thread = multi_thread;
}
if let Some(proxy_forward_by_system) = self.proxy_forward_by_system {
flags.proxy_forward_by_system = proxy_forward_by_system;
}
if let Some(disable_encryption) = self.disable_encryption {
flags.enable_encryption = !disable_encryption;
}
if self.enable_relay_network_whitelist.unwrap_or_default() {
if !self.relay_network_whitelist.is_empty() {
flags.relay_network_whitelist = self.relay_network_whitelist.join(" ");
} else {
flags.relay_network_whitelist = "".to_string();
}
}
if let Some(disable_tcp_hole_punching) = self.disable_tcp_hole_punching {
flags.disable_tcp_hole_punching = disable_tcp_hole_punching;
}
if let Some(disable_udp_hole_punching) = self.disable_udp_hole_punching {
flags.disable_udp_hole_punching = disable_udp_hole_punching;
}
if let Some(disable_upnp) = self.disable_upnp {
flags.disable_upnp = disable_upnp;
}
if let Some(disable_relay_data) = self.disable_relay_data {
flags.disable_relay_data = disable_relay_data;
}
if let Some(enable_udp_broadcast_relay) = self.enable_udp_broadcast_relay {
flags.enable_udp_broadcast_relay = enable_udp_broadcast_relay;
}
if let Some(disable_sym_hole_punching) = self.disable_sym_hole_punching {
flags.disable_sym_hole_punching = disable_sym_hole_punching;
}
if let Some(enable_magic_dns) = self.enable_magic_dns {
flags.accept_dns = enable_magic_dns;
}
if let Some(mtu) = self.mtu {
flags.mtu = mtu as u32;
}
if let Some(instance_recv_bps_limit) = self.instance_recv_bps_limit {
flags.instance_recv_bps_limit = instance_recv_bps_limit;
}
if let Some(enable_private_mode) = self.enable_private_mode {
flags.private_mode = enable_private_mode;
}
if let Some(encryption_algorithm) = self.encryption_algorithm.clone() {
flags.encryption_algorithm = encryption_algorithm;
}
if let Some(acl) = self.acl.as_ref()
&& !acl.is_empty()
{
cfg.set_acl(Some(acl.clone()));
}
if let Some(data_compress_algo) = self.data_compress_algo {
if data_compress_algo < 1 {
flags.data_compress_algo = 1;
} else {
flags.data_compress_algo = data_compress_algo
}
}
cfg.set_flags(flags);
Ok(cfg)
}
fn new_from_config(config: impl ConfigLoader) -> Result<Self, anyhow::Error> {
let default_config = TomlConfigLoader::default();
let mut result = Self {
..Default::default()
};
result.instance_id = Some(config.get_id().to_string());
if config.get_hostname() != default_config.get_hostname() {
result.hostname = Some(config.get_hostname());
}
result.dhcp = Some(config.get_dhcp());
let network_identity = config.get_network_identity();
result.network_name = Some(network_identity.network_name.clone());
result.network_secret = network_identity.network_secret;
if let Some(ipv4) = config.get_ipv4() {
result.virtual_ipv4 = Some(ipv4.address().to_string());
result.network_length = Some(ipv4.network_length() as i32);
}
if config.get_ipv6_public_addr_provider() != default_config.get_ipv6_public_addr_provider()
{
result.ipv6_public_addr_provider = Some(config.get_ipv6_public_addr_provider());
}
if config.get_ipv6_public_addr_auto() != default_config.get_ipv6_public_addr_auto() {
result.ipv6_public_addr_auto = Some(config.get_ipv6_public_addr_auto());
}
result.ipv6_public_addr_prefix = config
.get_ipv6_public_addr_prefix()
.map(|prefix| prefix.to_string());
let peers = config.get_peers();
result.networking_method = Some(NetworkingMethod::Manual as i32);
if !peers.is_empty() {
result.peer_urls = peers.iter().map(|p| p.uri.to_string()).collect();
result.peers = peers
.iter()
.map(|p| manage::NetworkPeerConfig {
uri: p.uri.to_string(),
peer_public_key: p.peer_public_key.clone(),
})
.collect();
}
result.listener_urls = config
.get_listeners()
.unwrap_or_default()
.iter()
.map(|l| l.to_string())
.collect();
result.proxy_cidrs = config
.get_proxy_cidrs()
.iter()
.map(|c| {
if let Some(mapped) = c.mapped_cidr {
format!("{}->{}", c.cidr, mapped)
} else {
c.cidr.to_string()
}
})
.collect();
let port_forwards = config.get_port_forwards();
if !port_forwards.is_empty() {
result.port_forwards = port_forwards
.iter()
.map(|f| manage::PortForwardConfig {
proto: f.proto.clone(),
bind_ip: f.bind_addr.ip().to_string(),
bind_port: f.bind_addr.port() as u32,
dst_ip: f.dst_addr.ip().to_string(),
dst_port: f.dst_addr.port() as u32,
})
.collect();
}
if let Some(vpn_config) = config.get_vpn_portal_config() {
result.enable_vpn_portal = Some(true);
let cidr = vpn_config.client_cidr;
result.vpn_portal_client_network_addr = Some(cidr.first_address().to_string());
result.vpn_portal_client_network_len = Some(cidr.network_length() as i32);
result.vpn_portal_listen_port = Some(vpn_config.wireguard_listen.port() as i32);
}
if let Some(routes) = config.get_routes()
&& !routes.is_empty()
{
result.enable_manual_routes = Some(true);
result.routes = routes.iter().map(|r| r.to_string()).collect();
}
let exit_nodes = config.get_exit_nodes();
if !exit_nodes.is_empty() {
result.exit_nodes = exit_nodes.iter().map(|n| n.to_string()).collect();
}
if let Some(socks5_portal) = config.get_socks5_portal() {
result.enable_socks5 = Some(true);
result.socks5_port = socks5_portal.port().map(|p| p as i32);
}
let mapped_listeners = config.get_mapped_listeners();
if !mapped_listeners.is_empty() {
result.mapped_listeners = mapped_listeners.iter().map(|l| l.to_string()).collect();
}
result.secure_mode = config.get_secure_mode();
result.credential_file = config
.get_credential_file()
.map(|path| path.to_string_lossy().into_owned());
let flags = config.get_flags();
let default_flags = default_config.get_flags();
result.latency_first = Some(flags.latency_first);
result.dev_name = Some(flags.dev_name.clone());
result.use_smoltcp = Some(flags.use_smoltcp);
result.disable_ipv6 = Some(!flags.enable_ipv6);
result.enable_kcp_proxy = Some(flags.enable_kcp_proxy);
result.disable_kcp_input = Some(flags.disable_kcp_input);
result.enable_quic_proxy = Some(flags.enable_quic_proxy);
result.disable_quic_input = Some(flags.disable_quic_input);
result.disable_p2p = Some(flags.disable_p2p);
result.p2p_only = Some(flags.p2p_only);
result.lazy_p2p = Some(flags.lazy_p2p);
result.bind_device = Some(flags.bind_device);
result.socket_mark = flags.socket_mark;
result.no_tun = Some(flags.no_tun);
result.enable_exit_node = Some(flags.enable_exit_node);
result.relay_all_peer_rpc = Some(flags.relay_all_peer_rpc);
result.need_p2p = Some(flags.need_p2p);
result.multi_thread = Some(flags.multi_thread);
result.proxy_forward_by_system = Some(flags.proxy_forward_by_system);
result.disable_encryption = Some(!flags.enable_encryption);
result.disable_tcp_hole_punching = Some(flags.disable_tcp_hole_punching);
result.disable_udp_hole_punching = Some(flags.disable_udp_hole_punching);
result.disable_upnp = Some(flags.disable_upnp);
result.disable_relay_data = Some(flags.disable_relay_data);
result.enable_udp_broadcast_relay = Some(flags.enable_udp_broadcast_relay);
result.disable_sym_hole_punching = Some(flags.disable_sym_hole_punching);
result.enable_magic_dns = Some(flags.accept_dns);
result.mtu = Some(flags.mtu as i32);
result.data_compress_algo = (flags.data_compress_algo != default_flags.data_compress_algo)
.then_some(flags.data_compress_algo);
result.encryption_algorithm = (flags.encryption_algorithm
!= default_flags.encryption_algorithm)
.then_some(flags.encryption_algorithm.clone());
result.instance_recv_bps_limit =
(flags.instance_recv_bps_limit != u64::MAX).then_some(flags.instance_recv_bps_limit);
result.enable_private_mode = Some(flags.private_mode);
result.acl = config.get_acl();
if flags.relay_network_whitelist == "*" {
result.enable_relay_network_whitelist = Some(false);
} else {
result.enable_relay_network_whitelist = Some(true);
if flags.relay_network_whitelist.is_empty() {
result.relay_network_whitelist = vec![];
} else {
result.relay_network_whitelist = flags
.relay_network_whitelist
.split_whitespace()
.map(|s| s.to_string())
.collect();
}
}
Ok(result)
}
}
+27
View File
@@ -0,0 +1,27 @@
use wasm_bindgen::prelude::*;
use super::{
api_input::{NetworkConfig, NetworkConfigExt},
toml::{ConfigLoader, TomlConfig},
};
fn js_error(error: impl std::fmt::Debug) -> JsValue {
JsValue::from_str(&format!("{error:?}"))
}
#[wasm_bindgen]
pub fn generate_config(config_json: &str) -> Result<String, JsValue> {
let config: NetworkConfig = serde_json::from_str(config_json).map_err(js_error)?;
config
.gen_config()
.map(|config| config.dump())
.map_err(js_error)
}
#[wasm_bindgen]
pub fn parse_config(toml_config: &str) -> Result<String, JsValue> {
let config = TomlConfig::new_from_str(toml_config)
.and_then(|config| NetworkConfig::new_from_config(&config))
.map_err(js_error)?;
serde_json::to_string(&config).map_err(js_error)
}
+73
View File
@@ -0,0 +1,73 @@
use std::{fmt, str::FromStr};
use strum::VariantArray;
/// Stable configuration vocabulary for every known encryption algorithm.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, VariantArray)]
pub enum EncryptionAlgorithm {
Xor,
#[default]
AesGcm,
Aes256Gcm,
ChaCha20,
}
impl EncryptionAlgorithm {
pub const fn as_str(self) -> &'static str {
match self {
Self::Xor => "xor",
Self::AesGcm => "aes-gcm",
Self::Aes256Gcm => "aes-256-gcm",
Self::ChaCha20 => "chacha20",
}
}
}
impl fmt::Display for EncryptionAlgorithm {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for EncryptionAlgorithm {
type Err = ();
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.to_ascii_lowercase().as_str() {
"xor" => Ok(Self::Xor),
"aes-gcm" | "openssl-aes-gcm" => Ok(Self::AesGcm),
"aes-256-gcm" | "openssl-aes-256-gcm" => Ok(Self::Aes256Gcm),
"chacha20" | "chacha20-poly1305" | "openssl-chacha20" => Ok(Self::ChaCha20),
_ => Err(()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_algorithm_names_are_stable() {
let cases = [
("xor", EncryptionAlgorithm::Xor),
("aes-gcm", EncryptionAlgorithm::AesGcm),
("aes-256-gcm", EncryptionAlgorithm::Aes256Gcm),
("chacha20", EncryptionAlgorithm::ChaCha20),
("chacha20-poly1305", EncryptionAlgorithm::ChaCha20),
("openssl-aes-gcm", EncryptionAlgorithm::AesGcm),
("openssl-aes-256-gcm", EncryptionAlgorithm::Aes256Gcm),
("openssl-chacha20", EncryptionAlgorithm::ChaCha20),
];
for (name, expected) in cases {
assert_eq!(name.parse(), Ok(expected));
}
assert_eq!(EncryptionAlgorithm::ChaCha20.to_string(), "chacha20");
}
#[test]
fn aes_is_the_stable_default() {
assert_eq!(EncryptionAlgorithm::default(), EncryptionAlgorithm::AesGcm);
}
}
+138
View File
@@ -0,0 +1,138 @@
use std::net::SocketAddr;
use serde::{Deserialize, Serialize};
use easytier_proto::common::{PortForwardConfigPb, SocketType};
/// Runtime configuration for the core-owned SOCKS and port-forward gateway.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct GatewayRuntimeConfig {
pub socks5_bind: Option<SocketAddr>,
pub port_forwards: Vec<PortForwardConfig>,
}
/// One TCP or UDP port-forward rule.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct PortForwardConfig {
pub bind_addr: SocketAddr,
pub dst_addr: SocketAddr,
pub proto: String,
}
impl From<PortForwardConfigPb> for PortForwardConfig {
fn from(config: PortForwardConfigPb) -> Self {
Self {
bind_addr: config.bind_addr.unwrap_or_default().into(),
dst_addr: config.dst_addr.unwrap_or_default().into(),
proto: match SocketType::try_from(config.socket_type) {
Ok(SocketType::Tcp) => "tcp".to_string(),
Ok(SocketType::Udp) => "udp".to_string(),
_ => "tcp".to_string(),
},
}
}
}
impl From<PortForwardConfig> for PortForwardConfigPb {
fn from(config: PortForwardConfig) -> Self {
Self {
bind_addr: Some(config.bind_addr.into()),
dst_addr: Some(config.dst_addr.into()),
socket_type: match config.proto.to_lowercase().as_str() {
"tcp" => SocketType::Tcp as i32,
"udp" => SocketType::Udp as i32,
_ => SocketType::Tcp as i32,
},
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProxyRuntimeConfig {
pub enable_exit_node: bool,
pub no_tun: bool,
pub forward_by_system: bool,
pub force_smoltcp: bool,
pub icmp_failure_is_fatal: bool,
pub udp_response_ipv4_mtu: usize,
}
impl ProxyRuntimeConfig {
pub fn should_start(self, has_proxy_networks: bool) -> bool {
if !has_proxy_networks && !self.enable_exit_node && !self.no_tun {
return false;
}
!self.forward_by_system || self.no_tun
}
}
impl Default for ProxyRuntimeConfig {
fn default() -> Self {
Self {
enable_exit_node: false,
no_tun: false,
forward_by_system: false,
force_smoltcp: false,
icmp_failure_is_fatal: false,
udp_response_ipv4_mtu: 1280,
}
}
}
#[cfg(test)]
mod tests {
use super::ProxyRuntimeConfig;
#[test]
fn proxy_startup_policy_preserves_runtime_modes() {
assert!(!ProxyRuntimeConfig::default().should_start(false));
assert!(ProxyRuntimeConfig::default().should_start(true));
assert!(
ProxyRuntimeConfig {
enable_exit_node: true,
..Default::default()
}
.should_start(false)
);
assert!(
ProxyRuntimeConfig {
no_tun: true,
..Default::default()
}
.should_start(false)
);
}
#[test]
fn proxy_startup_policy_preserves_system_forwarding_rules() {
assert!(
!ProxyRuntimeConfig {
forward_by_system: true,
..Default::default()
}
.should_start(true)
);
assert!(
!ProxyRuntimeConfig {
enable_exit_node: true,
forward_by_system: true,
..Default::default()
}
.should_start(false)
);
assert!(
ProxyRuntimeConfig {
no_tun: true,
forward_by_system: true,
..Default::default()
}
.should_start(false)
);
}
#[test]
fn proxy_runtime_defaults_preserve_udp_mtu() {
assert_eq!(ProxyRuntimeConfig::default().udp_response_ipv4_mtu, 1280);
}
}
+810
View File
@@ -0,0 +1,810 @@
//! Static configuration schema plus the live runtime configuration store.
#[cfg(feature = "web-client")]
pub mod api;
#[cfg(any(feature = "web-client", feature = "browser-config"))]
pub mod api_input;
#[cfg(all(
feature = "browser-config",
target_arch = "wasm32",
target_os = "unknown"
))]
mod browser;
mod encryption;
pub mod gateway;
pub mod peers;
pub mod runtime;
pub mod toml;
pub use encryption::EncryptionAlgorithm;
pub(crate) const DEFAULT_UDP_STUN_SERVERS: &[&str] = &[
"txt:stun.easytier.cn",
"stun.miwifi.com",
"stun.chat.bilibili.com",
"stun.hitv.com",
];
pub(crate) const DEFAULT_TCP_STUN_SERVERS: &[&str] = &[
"stun.hot-chilli.net",
"stun.fitauto.ru",
"fwa.lifesizecloud.com",
"global.turn.twilio.com",
"turn.cloudflare.com",
"stun.voip.blackberry.com",
"stun.radiojar.com",
];
pub(crate) const DEFAULT_UDP_V6_STUN_SERVERS: &[&str] = &["txt:stun-v6.easytier.cn"];
pub(crate) fn default_stun_servers(servers: &[&str]) -> Vec<String> {
servers.iter().map(ToString::to_string).collect()
}
use std::{
collections::{BTreeSet, hash_map::DefaultHasher},
hash::{Hash, Hasher},
net::IpAddr,
};
use anyhow::Context as _;
use base64::{Engine as _, prelude::BASE64_STANDARD};
use easytier_proto::{common as common_pb, core_config as pb};
use serde::{Deserialize, Serialize};
use url::Url;
pub type PeerId = u32;
pub type NetworkSecretDigest = [u8; 32];
/// Host capabilities used by the portable mapped-listener validation rule.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MappedListenerPolicy {
implicit_port_schemes: BTreeSet<String>,
}
impl MappedListenerPolicy {
pub fn new<I, S>(implicit_port_schemes: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self {
implicit_port_schemes: implicit_port_schemes
.into_iter()
.map(Into::into)
.map(|scheme: String| scheme.to_ascii_lowercase())
.collect(),
}
}
pub fn validate(&self, url: &Url) -> anyhow::Result<()> {
if url.port().is_none() && !self.implicit_port_schemes.contains(url.scheme()) {
anyhow::bail!("mapped listener port is missing: {}", url);
}
Ok(())
}
pub fn parse_urls(&self, mapped_listeners: &[String]) -> anyhow::Result<Vec<Url>> {
mapped_listeners
.iter()
.map(|value| {
let url: Url = value
.parse()
.with_context(|| format!("mapped listener is not a valid url: {}", value))?;
self.validate(&url)?;
Ok(url)
})
.collect()
}
}
/// Completes and validates the portable secure-mode key configuration.
pub fn normalize_secure_mode_config(
mut config: common_pb::SecureModeConfig,
) -> anyhow::Result<common_pb::SecureModeConfig> {
if !config.enabled {
return Ok(config);
}
let private_key = if config.local_private_key.is_none() {
let private = x25519_dalek::StaticSecret::random_from_rng(rand::rngs::OsRng);
config.local_private_key = Some(BASE64_STANDARD.encode(private.as_bytes()));
private
} else {
config.private_key()?
};
let generated_public_key = x25519_dalek::PublicKey::from(&private_key);
let generated_public_key = BASE64_STANDARD.encode(generated_public_key.as_bytes());
match config.local_public_key.as_ref() {
None => config.local_public_key = Some(generated_public_key),
Some(configured_public_key) => {
let public_key = config.public_key()?;
let canonical_public_key = BASE64_STANDARD.encode(public_key.as_bytes());
if configured_public_key != &canonical_public_key {
anyhow::bail!(
"local public key {} does not match generated public key {}",
configured_public_key,
canonical_public_key
);
}
}
}
Ok(config)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetworkIdentity {
pub network_name: String,
pub network_secret: Option<String>,
pub network_secret_digest: Option<NetworkSecretDigest>,
}
impl NetworkIdentity {
pub fn new(network_name: String, network_secret: String) -> Self {
Self {
network_secret_digest: Some(network_secret_digest(&network_name, &network_secret)),
network_name,
network_secret: Some(network_secret),
}
}
pub fn new_credential(network_name: String) -> Self {
Self {
network_name,
network_secret: None,
network_secret_digest: None,
}
}
pub fn secret_digest(&self) -> Option<NetworkSecretDigest> {
if self.network_secret_digest.is_some() {
self.network_secret_digest
} else if let Some(network_secret) = &self.network_secret {
let mut network_secret_digest = [0u8; 32];
generate_digest_from_str(
&self.network_name,
network_secret,
&mut network_secret_digest,
);
Some(network_secret_digest)
} else {
None
}
}
pub fn with_secret_digest(mut self) -> Self {
self.network_secret_digest = self.secret_digest();
self
}
}
#[derive(Eq, PartialEq, Hash)]
struct NetworkIdentityWithOnlyDigest {
network_name: String,
network_secret_digest: Option<NetworkSecretDigest>,
}
fn generate_digest_from_str(str1: &str, str2: &str, digest: &mut [u8]) {
let mut hasher = DefaultHasher::new();
hasher.write(str1.as_bytes());
hasher.write(str2.as_bytes());
assert_eq!(digest.len() % 8, 0, "digest length must be multiple of 8");
let shard_count = digest.len() / 8;
for i in 0..shard_count {
digest[i * 8..(i + 1) * 8].copy_from_slice(&hasher.finish().to_be_bytes());
hasher.write(&digest[..(i + 1) * 8]);
}
}
fn network_secret_digest(network_name: &str, network_secret: &str) -> NetworkSecretDigest {
let mut digest = [0u8; 32];
generate_digest_from_str(network_name, network_secret, &mut digest);
digest
}
impl From<NetworkIdentity> for NetworkIdentityWithOnlyDigest {
fn from(identity: NetworkIdentity) -> Self {
Self {
network_secret_digest: identity.secret_digest(),
network_name: identity.network_name,
}
}
}
impl PartialEq for NetworkIdentity {
fn eq(&self, other: &Self) -> bool {
let self_with_digest = NetworkIdentityWithOnlyDigest::from(self.clone());
let other_with_digest = NetworkIdentityWithOnlyDigest::from(other.clone());
self_with_digest == other_with_digest
}
}
impl Eq for NetworkIdentity {}
impl Hash for NetworkIdentity {
fn hash<H: Hasher>(&self, state: &mut H) {
let self_with_digest = NetworkIdentityWithOnlyDigest::from(self.clone());
self_with_digest.hash(state);
}
}
impl Default for NetworkIdentity {
fn default() -> Self {
Self::new("default".to_string(), "".to_string())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct CoreConfig {
pub node: NodeConfig,
pub routes: RouteConfig,
pub peer_policy: PeerPolicyConfig,
pub traffic: TrafficConfig,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct NodeConfig {
pub peer_id: Option<PeerId>,
pub instance_id: Option<[u8; 16]>,
pub hostname: Option<String>,
pub network_name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct RouteConfig {
pub ipv4: Option<IpPrefix>,
pub ipv6: Option<IpPrefix>,
pub advertised_routes: Vec<IpPrefix>,
pub proxy_networks: Vec<ProxyNetworkConfig>,
pub foreign_networks: Vec<ForeignNetworkConfig>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IpPrefix {
pub address: IpAddr,
pub prefix_len: u8,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProxyNetworkConfig {
pub real: IpPrefix,
pub mapped: Option<IpPrefix>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ForeignNetworkConfig {
pub name: String,
pub cidrs: Vec<IpPrefix>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeerPolicyConfig {
pub p2p_enabled: bool,
pub relay_peer_rpc: bool,
pub relay_data: bool,
pub latency_first: bool,
pub encryption_required: bool,
}
impl Default for PeerPolicyConfig {
fn default() -> Self {
Self {
p2p_enabled: true,
relay_peer_rpc: false,
relay_data: true,
latency_first: false,
encryption_required: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct P2pPolicyFlags {
pub disable_udp_hole_punching: bool,
pub disable_sym_hole_punching: bool,
pub disable_upnp: bool,
pub lazy_p2p: bool,
pub disable_p2p: bool,
pub need_p2p: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct TrafficConfig {
pub mtu: Option<u16>,
pub instance_recv_bps_limit: Option<u64>,
pub foreign_relay_bps_limit: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ConfigError {
#[error("missing required field: {0}")]
MissingField(&'static str),
#[error("invalid IPv4 prefix length: {0}")]
InvalidIpv4Prefix(u8),
#[error("invalid IPv6 prefix length: {0}")]
InvalidIpv6Prefix(u8),
#[error("invalid MTU: {0}")]
InvalidMtu(u32),
}
impl IpPrefix {
pub fn new(address: IpAddr, prefix_len: u8) -> Result<Self, ConfigError> {
match address {
IpAddr::V4(_) if prefix_len <= 32 => Ok(Self {
address,
prefix_len,
}),
IpAddr::V4(_) => Err(ConfigError::InvalidIpv4Prefix(prefix_len)),
IpAddr::V6(_) if prefix_len <= 128 => Ok(Self {
address,
prefix_len,
}),
IpAddr::V6(_) => Err(ConfigError::InvalidIpv6Prefix(prefix_len)),
}
}
}
impl TryFrom<pb::CoreConfig> for CoreConfig {
type Error = ConfigError;
fn try_from(value: pb::CoreConfig) -> Result<Self, Self::Error> {
Ok(Self {
node: value
.node
.map(TryInto::try_into)
.transpose()?
.unwrap_or_default(),
routes: value
.routes
.map(TryInto::try_into)
.transpose()?
.unwrap_or_default(),
peer_policy: value.peer_policy.map(Into::into).unwrap_or_default(),
traffic: value
.traffic
.map(TryInto::try_into)
.transpose()?
.unwrap_or_default(),
})
}
}
impl From<CoreConfig> for pb::CoreConfig {
fn from(value: CoreConfig) -> Self {
Self {
node: Some(value.node.into()),
routes: Some(value.routes.into()),
peer_policy: Some(value.peer_policy.into()),
traffic: Some(value.traffic.into()),
}
}
}
impl TryFrom<pb::NodeConfig> for NodeConfig {
type Error = ConfigError;
fn try_from(value: pb::NodeConfig) -> Result<Self, Self::Error> {
Ok(Self {
peer_id: value.peer_id,
instance_id: value.instance_id.map(uuid_to_bytes),
hostname: value.hostname,
network_name: value.network_name,
})
}
}
impl From<NodeConfig> for pb::NodeConfig {
fn from(value: NodeConfig) -> Self {
Self {
peer_id: value.peer_id,
instance_id: value.instance_id.map(uuid_from_bytes),
hostname: value.hostname,
network_name: value.network_name,
}
}
}
impl TryFrom<pb::RouteConfig> for RouteConfig {
type Error = ConfigError;
fn try_from(value: pb::RouteConfig) -> Result<Self, Self::Error> {
Ok(Self {
ipv4: value.ipv4.map(TryInto::try_into).transpose()?,
ipv6: value.ipv6.map(TryInto::try_into).transpose()?,
advertised_routes: value
.advertised_routes
.into_iter()
.map(TryInto::try_into)
.collect::<Result<_, _>>()?,
proxy_networks: value
.proxy_networks
.into_iter()
.map(TryInto::try_into)
.collect::<Result<_, _>>()?,
foreign_networks: value
.foreign_networks
.into_iter()
.map(TryInto::try_into)
.collect::<Result<_, _>>()?,
})
}
}
impl From<RouteConfig> for pb::RouteConfig {
fn from(value: RouteConfig) -> Self {
Self {
ipv4: value.ipv4.map(Into::into),
ipv6: value.ipv6.map(Into::into),
advertised_routes: value
.advertised_routes
.into_iter()
.map(Into::into)
.collect(),
proxy_networks: value.proxy_networks.into_iter().map(Into::into).collect(),
foreign_networks: value.foreign_networks.into_iter().map(Into::into).collect(),
}
}
}
impl TryFrom<pb::IpPrefix> for IpPrefix {
type Error = ConfigError;
fn try_from(value: pb::IpPrefix) -> Result<Self, Self::Error> {
let address = pb_ip_addr_to_std(
value
.address
.ok_or(ConfigError::MissingField("IpPrefix.address"))?,
)?;
let prefix_len = u8::try_from(value.prefix_len)
.map_err(|_| invalid_prefix_for_address(address, value.prefix_len))?;
Self::new(address, prefix_len)
}
}
impl From<IpPrefix> for pb::IpPrefix {
fn from(value: IpPrefix) -> Self {
Self {
address: Some(value.address.into()),
prefix_len: value.prefix_len.into(),
}
}
}
impl TryFrom<pb::ProxyNetworkConfig> for ProxyNetworkConfig {
type Error = ConfigError;
fn try_from(value: pb::ProxyNetworkConfig) -> Result<Self, Self::Error> {
Ok(Self {
real: value
.real
.ok_or(ConfigError::MissingField("ProxyNetworkConfig.real"))?
.try_into()?,
mapped: value.mapped.map(TryInto::try_into).transpose()?,
})
}
}
impl From<ProxyNetworkConfig> for pb::ProxyNetworkConfig {
fn from(value: ProxyNetworkConfig) -> Self {
Self {
real: Some(value.real.into()),
mapped: value.mapped.map(Into::into),
}
}
}
impl TryFrom<pb::ForeignNetworkConfig> for ForeignNetworkConfig {
type Error = ConfigError;
fn try_from(value: pb::ForeignNetworkConfig) -> Result<Self, Self::Error> {
Ok(Self {
name: value.name,
cidrs: value
.cidrs
.into_iter()
.map(TryInto::try_into)
.collect::<Result<_, _>>()?,
})
}
}
impl From<ForeignNetworkConfig> for pb::ForeignNetworkConfig {
fn from(value: ForeignNetworkConfig) -> Self {
Self {
name: value.name,
cidrs: value.cidrs.into_iter().map(Into::into).collect(),
}
}
}
impl From<pb::PeerPolicyConfig> for PeerPolicyConfig {
fn from(value: pb::PeerPolicyConfig) -> Self {
let default = Self::default();
Self {
p2p_enabled: value.p2p_enabled.unwrap_or(default.p2p_enabled),
relay_peer_rpc: value.relay_peer_rpc.unwrap_or(default.relay_peer_rpc),
relay_data: value.relay_data.unwrap_or(default.relay_data),
latency_first: value.latency_first.unwrap_or(default.latency_first),
encryption_required: value
.encryption_required
.unwrap_or(default.encryption_required),
}
}
}
impl From<PeerPolicyConfig> for pb::PeerPolicyConfig {
fn from(value: PeerPolicyConfig) -> Self {
Self {
p2p_enabled: Some(value.p2p_enabled),
relay_peer_rpc: Some(value.relay_peer_rpc),
relay_data: Some(value.relay_data),
latency_first: Some(value.latency_first),
encryption_required: Some(value.encryption_required),
}
}
}
impl TryFrom<pb::TrafficConfig> for TrafficConfig {
type Error = ConfigError;
fn try_from(value: pb::TrafficConfig) -> Result<Self, Self::Error> {
Ok(Self {
mtu: value
.mtu
.map(|mtu| u16::try_from(mtu).map_err(|_| ConfigError::InvalidMtu(mtu)))
.transpose()?,
instance_recv_bps_limit: value.instance_recv_bps_limit,
foreign_relay_bps_limit: value.foreign_relay_bps_limit,
})
}
}
impl From<TrafficConfig> for pb::TrafficConfig {
fn from(value: TrafficConfig) -> Self {
Self {
mtu: value.mtu.map(Into::into),
instance_recv_bps_limit: value.instance_recv_bps_limit,
foreign_relay_bps_limit: value.foreign_relay_bps_limit,
}
}
}
fn pb_ip_addr_to_std(value: common_pb::IpAddr) -> Result<IpAddr, ConfigError> {
match value.ip.ok_or(ConfigError::MissingField("IpAddr.ip"))? {
common_pb::ip_addr::Ip::Ipv4(addr) => Ok(IpAddr::V4(addr.into())),
common_pb::ip_addr::Ip::Ipv6(addr) => Ok(IpAddr::V6(addr.into())),
}
}
fn invalid_prefix_for_address(address: IpAddr, prefix_len: u32) -> ConfigError {
let prefix_len = u8::try_from(prefix_len).unwrap_or(u8::MAX);
match address {
IpAddr::V4(_) => ConfigError::InvalidIpv4Prefix(prefix_len),
IpAddr::V6(_) => ConfigError::InvalidIpv6Prefix(prefix_len),
}
}
fn uuid_to_bytes(value: common_pb::Uuid) -> [u8; 16] {
let mut bytes = [0; 16];
bytes[0..4].copy_from_slice(&value.part1.to_be_bytes());
bytes[4..8].copy_from_slice(&value.part2.to_be_bytes());
bytes[8..12].copy_from_slice(&value.part3.to_be_bytes());
bytes[12..16].copy_from_slice(&value.part4.to_be_bytes());
bytes
}
fn uuid_from_bytes(bytes: [u8; 16]) -> common_pb::Uuid {
common_pb::Uuid {
part1: u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]),
part2: u32::from_be_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]),
part3: u32::from_be_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]),
part4: u32::from_be_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]),
}
}
#[cfg(test)]
mod tests {
use super::*;
use base64::prelude::BASE64_STANDARD;
use x25519_dalek::{PublicKey, StaticSecret};
fn digest(network_name: &str, network_secret: &str) -> NetworkSecretDigest {
let mut digest = [0u8; 32];
generate_digest_from_str(network_name, network_secret, &mut digest);
digest
}
#[test]
fn network_identity_matches_secret_to_digest_identity() {
let local = NetworkIdentity {
network_name: "net".to_string(),
network_secret: Some("secret".to_string()),
network_secret_digest: None,
};
let remote = NetworkIdentity {
network_name: "net".to_string(),
network_secret: None,
network_secret_digest: Some(digest("net", "secret")),
};
assert_eq!(local, remote);
}
#[test]
fn network_identity_rejects_different_digest() {
let local = NetworkIdentity {
network_name: "net".to_string(),
network_secret: Some("secret".to_string()),
network_secret_digest: None,
};
let remote = NetworkIdentity {
network_name: "net".to_string(),
network_secret: None,
network_secret_digest: Some(digest("net", "other")),
};
assert_ne!(local, remote);
}
#[test]
fn network_identity_equal_values_have_equal_hash() {
let local = NetworkIdentity {
network_name: "net".to_string(),
network_secret: Some("secret".to_string()),
network_secret_digest: None,
};
let remote = NetworkIdentity {
network_name: "net".to_string(),
network_secret: None,
network_secret_digest: Some(digest("net", "secret")),
};
let mut local_hasher = DefaultHasher::new();
let mut remote_hasher = DefaultHasher::new();
local.hash(&mut local_hasher);
remote.hash(&mut remote_hasher);
assert_eq!(local_hasher.finish(), remote_hasher.finish());
}
#[test]
fn network_identity_derives_digest_from_plaintext_secret() {
let identity = NetworkIdentity {
network_name: "net".to_string(),
network_secret: Some("secret".to_string()),
network_secret_digest: None,
};
assert_eq!(identity.secret_digest(), Some(digest("net", "secret")));
}
#[test]
fn network_identity_default_matches_native_default_network() {
assert_eq!(
NetworkIdentity::default(),
NetworkIdentity::new("default".to_string(), "".to_string())
);
}
#[test]
fn mapped_listener_policy_uses_explicit_host_capabilities() {
let policy = MappedListenerPolicy::new(["tcp", "ws", "wss"]);
let parsed = policy
.parse_urls(&[
"tcp://127.0.0.1".to_string(),
"ws://example.com".to_string(),
"wss://example.com/path".to_string(),
"ring://peer-id:1000".to_string(),
])
.unwrap();
assert_eq!(parsed.len(), 4);
assert_eq!(parsed[0].scheme(), "tcp");
assert_eq!(parsed[1].scheme(), "ws");
assert_eq!(parsed[2].scheme(), "wss");
assert_eq!(parsed[3].port(), Some(1000));
let error = policy
.parse_urls(&["ring://peer-id".to_string()])
.unwrap_err();
assert!(
error
.to_string()
.contains("mapped listener port is missing")
);
}
#[test]
fn secure_mode_normalization_generates_missing_key_pair() {
let normalized = normalize_secure_mode_config(common_pb::SecureModeConfig {
enabled: true,
local_private_key: None,
local_public_key: None,
})
.unwrap();
let private_key = normalized.private_key().unwrap();
let public_key = normalized.public_key().unwrap();
assert_eq!(public_key, PublicKey::from(&private_key));
}
#[test]
fn secure_mode_normalization_preserves_existing_key_configuration() {
let private_key = StaticSecret::from([7; 32]);
let public_key = PublicKey::from(&private_key);
let config = common_pb::SecureModeConfig {
enabled: true,
local_private_key: Some(BASE64_STANDARD.encode(private_key.as_bytes())),
local_public_key: Some(BASE64_STANDARD.encode(public_key.as_bytes())),
};
assert_eq!(
normalize_secure_mode_config(config.clone()).unwrap(),
config
);
}
#[test]
fn disabled_secure_mode_does_not_validate_keys() {
let config = common_pb::SecureModeConfig {
enabled: false,
local_private_key: Some("not-base64".to_string()),
local_public_key: Some("not-base64".to_string()),
};
assert_eq!(
normalize_secure_mode_config(config.clone()).unwrap(),
config
);
}
#[test]
fn validates_ip_prefix_lengths() {
assert!(IpPrefix::new("10.0.0.1".parse().unwrap(), 24).is_ok());
assert_eq!(
IpPrefix::new("10.0.0.1".parse().unwrap(), 33),
Err(ConfigError::InvalidIpv4Prefix(33))
);
assert!(IpPrefix::new("2001:db8::1".parse().unwrap(), 64).is_ok());
assert_eq!(
IpPrefix::new("2001:db8::1".parse().unwrap(), 129),
Err(ConfigError::InvalidIpv6Prefix(129))
);
}
#[test]
fn converts_core_config_from_proto_defaults() {
let config = CoreConfig::try_from(pb::CoreConfig {
node: Some(pb::NodeConfig {
peer_id: Some(7),
instance_id: None,
hostname: Some("node-a".to_string()),
network_name: "net".to_string(),
}),
routes: None,
peer_policy: None,
traffic: Some(pb::TrafficConfig {
mtu: Some(1380),
instance_recv_bps_limit: Some(100),
foreign_relay_bps_limit: None,
}),
})
.unwrap();
assert_eq!(config.node.peer_id, Some(7));
assert_eq!(config.node.hostname.as_deref(), Some("node-a"));
assert!(config.peer_policy.p2p_enabled);
assert_eq!(config.traffic.mtu, Some(1380));
}
#[test]
fn converts_ip_prefix_round_trip() {
let prefix = IpPrefix::new("10.1.0.1".parse().unwrap(), 16).unwrap();
let pb: pb::IpPrefix = prefix.clone().into();
assert_eq!(IpPrefix::try_from(pb).unwrap(), prefix);
}
}
+315
View File
@@ -0,0 +1,315 @@
//! Peer-flavored configuration data owned by the config layer.
//!
//! These types are pure serializable configuration snapshots. Normalization
//! and derivation behavior that depends on peer-domain logic stays in
//! `crate::peers`.
use anyhow::Context as _;
use cidr::{Ipv4Cidr, Ipv6Cidr};
use easytier_proto::common::{FlagsInConfig, PeerFeatureFlag, SecureModeConfig, StunInfo};
use serde::{Deserialize, Serialize};
use crate::proto::acl::{Acl, AclV1, Action, Chain, ChainType, GroupInfo, Protocol, Rule};
use super::{CoreConfig, NetworkIdentity};
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct AclRuleConfig {
pub acl: Option<Acl>,
pub tcp_whitelist: Vec<String>,
pub udp_whitelist: Vec<String>,
pub whitelist_priority: Option<u32>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AclWhitelistSnapshot {
pub tcp_ports: Vec<String>,
pub udp_ports: Vec<String>,
}
impl From<&AclRuleConfig> for AclWhitelistSnapshot {
fn from(config: &AclRuleConfig) -> Self {
Self {
tcp_ports: config.tcp_whitelist.clone(),
udp_ports: config.udp_whitelist.clone(),
}
}
}
impl AclRuleConfig {
fn parse_port_list(port_list: &[String]) -> anyhow::Result<Vec<String>> {
let mut ports = Vec::new();
for port_spec in port_list {
if port_spec.contains('-') {
let parts: Vec<&str> = port_spec.split('-').collect();
if parts.len() != 2 {
return Err(anyhow::anyhow!("Invalid port range format: {}", port_spec));
}
let start: u16 = parts[0]
.parse()
.with_context(|| format!("Invalid start port in range: {}", port_spec))?;
let end: u16 = parts[1]
.parse()
.with_context(|| format!("Invalid end port in range: {}", port_spec))?;
if start > end {
return Err(anyhow::anyhow!(
"Start port must be <= end port in range: {}",
port_spec
));
}
ports.push(port_spec.clone());
} else {
let port: u16 = port_spec
.parse()
.with_context(|| format!("Invalid port number: {}", port_spec))?;
ports.push(port.to_string());
}
}
Ok(ports)
}
fn generate_acl_from_whitelists(&mut self) -> anyhow::Result<()> {
if self.tcp_whitelist.is_empty() && self.udp_whitelist.is_empty() {
return Ok(());
}
let mut inbound_chain = Chain {
name: "inbound_whitelist".to_string(),
chain_type: ChainType::Inbound as i32,
description: "Auto-generated inbound whitelist from CLI".to_string(),
enabled: true,
rules: vec![],
default_action: Action::Allow as i32,
};
let mut rule_priority = self.whitelist_priority.unwrap_or(1000u32);
if !self.tcp_whitelist.is_empty() {
let tcp_ports = Self::parse_port_list(&self.tcp_whitelist)?;
inbound_chain.rules.push(Rule {
name: "tcp_whitelist".to_string(),
description: "Auto-generated TCP whitelist rule".to_string(),
priority: rule_priority,
enabled: true,
protocol: Protocol::Tcp as i32,
ports: tcp_ports,
source_ips: vec![],
destination_ips: vec![],
source_ports: vec![],
action: Action::Allow as i32,
rate_limit: 0,
burst_limit: 0,
stateful: true,
source_groups: vec![],
destination_groups: vec![],
});
inbound_chain.rules.push(Rule {
name: "tcp_whitelist_deny_other".to_string(),
description: "Auto-generated TCP whitelist rule to deny other ports".to_string(),
priority: 0,
enabled: true,
protocol: Protocol::Tcp as i32,
ports: vec!["0-65535".to_string()],
source_ips: vec![],
destination_ips: vec![],
source_ports: vec![],
action: Action::Drop as i32,
rate_limit: 0,
burst_limit: 0,
stateful: false,
source_groups: vec![],
destination_groups: vec![],
});
rule_priority -= 1;
}
if !self.udp_whitelist.is_empty() {
let udp_ports = Self::parse_port_list(&self.udp_whitelist)?;
inbound_chain.rules.push(Rule {
name: "udp_whitelist".to_string(),
description: "Auto-generated UDP whitelist rule".to_string(),
priority: rule_priority,
enabled: true,
protocol: Protocol::Udp as i32,
ports: udp_ports,
source_ips: vec![],
destination_ips: vec![],
source_ports: vec![],
action: Action::Allow as i32,
rate_limit: 0,
burst_limit: 0,
stateful: false,
source_groups: vec![],
destination_groups: vec![],
});
inbound_chain.rules.push(Rule {
name: "udp_whitelist_deny_other".to_string(),
description: "Auto-generated UDP whitelist rule to deny other ports".to_string(),
priority: 0,
enabled: true,
protocol: Protocol::Udp as i32,
ports: vec!["0-65535".to_string()],
source_ips: vec![],
destination_ips: vec![],
source_ports: vec![],
action: Action::Drop as i32,
rate_limit: 0,
burst_limit: 0,
stateful: false,
source_groups: vec![],
destination_groups: vec![],
});
}
if self.acl.is_none() {
self.acl = Some(Acl::default());
}
let acl = self.acl.as_mut().expect("ACL was initialized above");
if let Some(acl_v1) = acl.acl_v1.as_mut() {
acl_v1.chains.push(inbound_chain);
} else {
acl.acl_v1 = Some(AclV1 {
chains: vec![inbound_chain],
group: Some(GroupInfo {
declares: vec![],
members: vec![],
}),
});
}
Ok(())
}
pub fn build(&self) -> anyhow::Result<Option<Acl>> {
let mut config = self.clone();
config.generate_acl_from_whitelists()?;
Ok(config.acl)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct PublicIpv6ProviderConfig {
pub provider_enabled: bool,
pub configured_prefix: Option<Ipv6Cidr>,
pub provider_supported: bool,
}
impl PublicIpv6ProviderConfig {
pub fn should_run_reconcile(self) -> bool {
self.provider_enabled
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PeerRuntimeConfig {
pub core: CoreConfig,
pub network_identity: NetworkIdentity,
pub stun_info: StunInfo,
pub feature_flags: PeerFeatureFlag,
pub secure_mode: Option<SecureModeConfig>,
pub host_routing: HostRoutingPolicy,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostRoutingPolicy {
/// Route otherwise-unreachable external IPv4 traffic through this node and
/// keep self-delivered packets eligible for the host TUN/proxy path.
pub local_exit_node_fallback: bool,
}
/// One normalized peer configuration version submitted by a host.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PeerRuntimeSnapshot {
pub runtime: PeerRuntimeConfig,
pub easytier_version: String,
pub avoid_relay_data_preference: bool,
pub flags: FlagsInConfig,
pub vpn_portal_cidr: Option<Ipv4Cidr>,
pub pinned_peers: Vec<(url::Url, Option<String>)>,
pub peer_group_memberships: Vec<PeerGroupIdentity>,
pub acl_group_declarations: Vec<PeerGroupIdentity>,
pub ospf_update_my_foreign_network_interval_sec: u64,
pub max_direct_conns_per_peer_in_foreign_network: usize,
pub hmac_secret_digest: bool,
}
impl PeerRuntimeSnapshot {
pub fn new(runtime: PeerRuntimeConfig, flags: FlagsInConfig) -> Self {
let avoid_relay_data_preference = runtime.feature_flags.avoid_relay_data;
Self {
runtime,
easytier_version: env!("CARGO_PKG_VERSION").to_owned(),
avoid_relay_data_preference,
flags,
vpn_portal_cidr: None,
pinned_peers: Vec::new(),
peer_group_memberships: Vec::new(),
acl_group_declarations: Vec::new(),
ospf_update_my_foreign_network_interval_sec: 10,
max_direct_conns_per_peer_in_foreign_network: 3,
hmac_secret_digest: false,
}
}
}
impl Default for PeerRuntimeSnapshot {
fn default() -> Self {
Self::new(
PeerRuntimeConfig {
core: CoreConfig::default(),
network_identity: NetworkIdentity::default(),
stun_info: StunInfo::default(),
feature_flags: PeerFeatureFlag::default(),
secure_mode: None,
host_routing: HostRoutingPolicy::default(),
},
FlagsInConfig::default(),
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeerGroupIdentity {
pub group_name: String,
pub group_secret: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn whitelist_rules_are_built_in_core() {
let acl = AclRuleConfig {
tcp_whitelist: vec!["80".to_string(), "8000-9000".to_string()],
udp_whitelist: vec!["53".to_string()],
..Default::default()
}
.build()
.unwrap()
.unwrap();
let chain = &acl.acl_v1.unwrap().chains[0];
assert_eq!(chain.name, "inbound_whitelist");
assert_eq!(chain.rules.len(), 4);
assert_eq!(chain.rules[0].ports, ["80", "8000-9000"]);
assert_eq!(chain.rules[2].ports, ["53"]);
}
#[test]
fn invalid_whitelist_range_is_rejected() {
let error = AclRuleConfig {
tcp_whitelist: vec!["9000-8000".to_string()],
..Default::default()
}
.build()
.unwrap_err();
assert!(error.to_string().contains("Start port must be <= end port"));
}
}
+275
View File
@@ -0,0 +1,275 @@
//! Atomic runtime configuration owned by one core instance.
use std::{collections::BTreeSet, sync::Arc};
use arc_swap::ArcSwap;
use cidr::Ipv4Cidr;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use super::{
gateway::{GatewayRuntimeConfig, ProxyRuntimeConfig},
peers::{AclRuleConfig, PeerRuntimeSnapshot, PublicIpv6ProviderConfig},
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CoreRuntimeConfig {
pub acl: AclRuleConfig,
pub dhcp_ipv4: bool,
pub gateway: GatewayRuntimeConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub manual_routes: Option<BTreeSet<Ipv4Cidr>>,
pub proxy: ProxyRuntimeConfig,
#[serde(default)]
pub public_ipv6_auto: bool,
pub public_ipv6_provider: PublicIpv6ProviderConfig,
}
impl Default for CoreRuntimeConfig {
fn default() -> Self {
Self {
acl: AclRuleConfig::default(),
dhcp_ipv4: false,
gateway: GatewayRuntimeConfig::default(),
manual_routes: None,
proxy: ProxyRuntimeConfig::default(),
public_ipv6_auto: false,
public_ipv6_provider: PublicIpv6ProviderConfig {
provider_enabled: false,
configured_prefix: None,
provider_supported: false,
},
}
}
}
#[derive(Debug, Clone)]
pub struct CoreInstanceRuntimeConfig {
pub services: CoreRuntimeConfig,
pub peer: Arc<PeerRuntimeSnapshot>,
}
struct CoreRuntimeConfigStoreInner {
snapshot: ArcSwap<CoreInstanceRuntimeConfig>,
update: Mutex<()>,
peer_changes: tokio::sync::watch::Sender<u64>,
service_changes: tokio::sync::watch::Sender<u64>,
}
/// Atomic configuration authority shared by one core instance and its peer
/// context. Readers always observe a complete submitted version.
#[derive(Clone)]
pub struct CoreRuntimeConfigStore {
inner: Arc<CoreRuntimeConfigStoreInner>,
}
impl CoreRuntimeConfigStore {
pub fn new(services: CoreRuntimeConfig, peer: Arc<PeerRuntimeSnapshot>) -> Self {
let (peer_changes, _) = tokio::sync::watch::channel(0);
let (service_changes, _) = tokio::sync::watch::channel(0);
Self {
inner: Arc::new(CoreRuntimeConfigStoreInner {
snapshot: ArcSwap::from_pointee(CoreInstanceRuntimeConfig { services, peer }),
update: Mutex::new(()),
peer_changes,
service_changes,
}),
}
}
pub fn snapshot(&self) -> Arc<CoreInstanceRuntimeConfig> {
self.inner.snapshot.load_full()
}
pub(crate) fn with_snapshot<T>(&self, read: impl FnOnce(&CoreInstanceRuntimeConfig) -> T) -> T {
let snapshot = self.inner.snapshot.load();
read(&snapshot)
}
pub fn replace(&self, config: CoreInstanceRuntimeConfig) {
let _update = self.inner.update.lock();
self.inner.snapshot.store(Arc::new(config));
self.inner.peer_changes.send_modify(|version| *version += 1);
self.inner
.service_changes
.send_modify(|version| *version += 1);
}
pub(crate) fn replace_with_current(
&self,
mut config: CoreInstanceRuntimeConfig,
merge: impl FnOnce(&CoreInstanceRuntimeConfig, &mut CoreInstanceRuntimeConfig),
) -> Arc<CoreInstanceRuntimeConfig> {
let _update = self.inner.update.lock();
let current = self.inner.snapshot.load_full();
merge(&current, &mut config);
let config = Arc::new(config);
self.inner.snapshot.store(config.clone());
self.inner.peer_changes.send_modify(|version| *version += 1);
self.inner
.service_changes
.send_modify(|version| *version += 1);
config
}
pub fn update_services(&self, update: impl FnOnce(&mut CoreRuntimeConfig)) {
let _update = self.inner.update.lock();
let mut config = self.inner.snapshot.load_full().as_ref().clone();
update(&mut config.services);
self.inner.snapshot.store(Arc::new(config));
self.inner
.service_changes
.send_modify(|version| *version += 1);
}
pub fn update_peer(&self, peer: Arc<PeerRuntimeSnapshot>) {
let _update = self.inner.update.lock();
let mut config = self.inner.snapshot.load_full().as_ref().clone();
config.peer = peer;
self.inner.snapshot.store(Arc::new(config));
self.inner.peer_changes.send_modify(|version| *version += 1);
}
pub(crate) fn update_peer_with(&self, update: impl FnOnce(&mut PeerRuntimeSnapshot)) {
let _update = self.inner.update.lock();
let mut config = self.inner.snapshot.load_full().as_ref().clone();
update(Arc::make_mut(&mut config.peer));
self.inner.snapshot.store(Arc::new(config));
self.inner.peer_changes.send_modify(|version| *version += 1);
}
pub fn subscribe_peer_runtime_changes(&self) -> tokio::sync::watch::Receiver<u64> {
self.inner.peer_changes.subscribe()
}
pub fn subscribe_service_runtime_changes(&self) -> tokio::sync::watch::Receiver<u64> {
self.inner.service_changes.subscribe()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn replaces_service_and_peer_as_one_version() {
let mut before_peer = PeerRuntimeSnapshot::default();
before_peer.runtime.core.node.hostname = Some("before".to_owned());
let store =
CoreRuntimeConfigStore::new(CoreRuntimeConfig::default(), Arc::new(before_peer));
let before = store.snapshot();
let after_services = CoreRuntimeConfig {
dhcp_ipv4: true,
..Default::default()
};
let mut after_peer = PeerRuntimeSnapshot::default();
after_peer.runtime.core.node.hostname = Some("after".to_owned());
store.replace(CoreInstanceRuntimeConfig {
services: after_services,
peer: Arc::new(after_peer),
});
assert!(!before.services.dhcp_ipv4);
assert_eq!(
before.peer.runtime.core.node.hostname.as_deref(),
Some("before")
);
let after = store.snapshot();
assert!(after.services.dhcp_ipv4);
assert_eq!(
after.peer.runtime.core.node.hostname.as_deref(),
Some("after")
);
}
#[tokio::test]
async fn notifies_peer_snapshot_changes() {
let store = CoreRuntimeConfigStore::new(
CoreRuntimeConfig::default(),
Arc::new(PeerRuntimeSnapshot::default()),
);
let mut changes = store.subscribe_peer_runtime_changes();
let mut peer = PeerRuntimeSnapshot::default();
peer.runtime.core.node.hostname = Some("updated".to_owned());
store.update_peer(Arc::new(peer));
assert!(changes.changed().await.is_ok());
}
#[tokio::test]
async fn notifies_service_snapshot_changes() {
let store = CoreRuntimeConfigStore::new(
CoreRuntimeConfig::default(),
Arc::new(PeerRuntimeSnapshot::default()),
);
let mut changes = store.subscribe_service_runtime_changes();
store.update_services(|services| services.dhcp_ipv4 = true);
assert!(changes.changed().await.is_ok());
assert!(store.snapshot().services.dhcp_ipv4);
}
#[tokio::test]
async fn peer_update_does_not_notify_service_watchers() {
let store = CoreRuntimeConfigStore::new(
CoreRuntimeConfig::default(),
Arc::new(PeerRuntimeSnapshot::default()),
);
let changes = store.subscribe_service_runtime_changes();
let mut peer = PeerRuntimeSnapshot::default();
peer.runtime.core.node.hostname = Some("updated".to_owned());
store.update_peer(Arc::new(peer));
assert!(!changes.has_changed().unwrap());
}
#[test]
fn peer_in_place_update_preserves_the_rest_of_the_atomic_snapshot() {
let services = CoreRuntimeConfig {
dhcp_ipv4: true,
..Default::default()
};
let mut peer = PeerRuntimeSnapshot::default();
peer.runtime.core.node.hostname = Some("preserved".to_owned());
let store = CoreRuntimeConfigStore::new(services, Arc::new(peer));
store.update_peer_with(|peer| {
peer.runtime.core.routes.ipv4 = Some(crate::config::IpPrefix {
address: "10.20.30.7".parse().unwrap(),
prefix_len: 24,
});
});
let snapshot = store.snapshot();
assert!(snapshot.services.dhcp_ipv4);
assert_eq!(
snapshot.peer.runtime.core.node.hostname.as_deref(),
Some("preserved")
);
assert_eq!(
snapshot
.peer
.runtime
.core
.routes
.ipv4
.as_ref()
.unwrap()
.address,
"10.20.30.7".parse::<std::net::IpAddr>().unwrap()
);
}
#[test]
fn missing_manual_routes_preserves_portable_config_compatibility() {
let encoded = serde_json::to_value(CoreRuntimeConfig::default()).unwrap();
assert!(encoded.get("manual_routes").is_none());
let decoded: CoreRuntimeConfig = serde_json::from_value(encoded).unwrap();
assert_eq!(decoded.manual_routes, None);
}
}

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