Compare commits

..
370 Commits
Author SHA1 Message Date
fanyangandGitHub 8d200c26d7 perf: tune jemalloc RSS decay for faster memory reclamation (#2428)
EasyTier Core / pre_job (push) Waiting to run
EasyTier Core / build_web (push) Blocked by required conditions
EasyTier Core / build (freebsd-13.2-x86_64, 13.2, ubuntu-24.04, x86_64-unknown-freebsd) (push) Blocked by required conditions
EasyTier Core / build (linux-aarch64, ubuntu-24.04-arm, aarch64-unknown-linux-musl) (push) Blocked by required conditions
EasyTier Core / build (linux-arm, ubuntu-24.04, arm-unknown-linux-musleabi) (push) Blocked by required conditions
EasyTier Core / build (linux-armhf, ubuntu-24.04, arm-unknown-linux-musleabihf) (push) Blocked by required conditions
EasyTier Core / build (linux-armv7, ubuntu-24.04, armv7-unknown-linux-musleabi) (push) Blocked by required conditions
EasyTier Core / build (linux-armv7hf, ubuntu-24.04, armv7-unknown-linux-musleabihf) (push) Blocked by required conditions
EasyTier Core / build (linux-loongarch64, ubuntu-24.04, loongarch64-unknown-linux-musl) (push) Blocked by required conditions
EasyTier Core / build (linux-mips, ubuntu-24.04, mips-unknown-linux-musl) (push) Blocked by required conditions
EasyTier Core / build (linux-mipsel, ubuntu-24.04, mipsel-unknown-linux-musl) (push) Blocked by required conditions
EasyTier Core / build (linux-riscv64, ubuntu-24.04, riscv64gc-unknown-linux-musl) (push) Blocked by required conditions
EasyTier Core / build (linux-x86_64, ubuntu-24.04, x86_64-unknown-linux-musl) (push) Blocked by required conditions
EasyTier Core / build (macos-aarch64, macos-latest, aarch64-apple-darwin) (push) Blocked by required conditions
EasyTier Core / build (macos-x86_64, macos-latest, x86_64-apple-darwin) (push) Blocked by required conditions
EasyTier Core / build (windows-arm64, windows-11-arm, aarch64-pc-windows-msvc) (push) Blocked by required conditions
EasyTier Core / build (windows-i686, windows-latest, i686-pc-windows-msvc) (push) Blocked by required conditions
EasyTier Core / build (windows-x86_64, windows-latest, x86_64-pc-windows-msvc) (push) Blocked by required conditions
EasyTier Core / build_magisk (push) Blocked by required conditions
EasyTier Core / core-result (push) Blocked by required conditions
EasyTier Go / Test Go modules (push) Waiting to run
EasyTier GUI / pre_job (push) Waiting to run
EasyTier GUI / build-gui (linux-aarch64, aarch64-unknown-linux-gnu, ubuntu-24.04-arm, aarch64-unknown-linux-musl) (push) Blocked by required conditions
EasyTier GUI / build-gui (linux-x86_64, x86_64-unknown-linux-gnu, ubuntu-24.04, x86_64-unknown-linux-musl) (push) Blocked by required conditions
EasyTier GUI / build-gui (macos-aarch64, aarch64-apple-darwin, macos-latest, aarch64-apple-darwin) (push) Blocked by required conditions
EasyTier GUI / build-gui (macos-x86_64, x86_64-apple-darwin, macos-latest, x86_64-apple-darwin) (push) Blocked by required conditions
EasyTier GUI / build-gui (windows-arm64, aarch64-pc-windows-msvc, windows-11-arm, aarch64-pc-windows-msvc) (push) Blocked by required conditions
EasyTier GUI / build-gui (windows-i686, i686-pc-windows-msvc, windows-latest, i686-pc-windows-msvc) (push) Blocked by required conditions
EasyTier GUI / build-gui (windows-x86_64, x86_64-pc-windows-msvc, windows-latest, x86_64-pc-windows-msvc) (push) Blocked by required conditions
EasyTier GUI / gui-result (push) Blocked by required conditions
EasyTier JavaScript Hosts / check (push) Waiting to run
EasyTier Mobile / pre_job (push) Waiting to run
EasyTier Mobile / build-mobile (aarch64, aarch64-linux-android) (push) Blocked by required conditions
EasyTier Mobile / build-mobile (armv7, armv7-linux-androideabi) (push) Blocked by required conditions
EasyTier Mobile / build-mobile (i686, i686-linux-android) (push) Blocked by required conditions
EasyTier Mobile / build-mobile (x86_64, x86_64-linux-android) (push) Blocked by required conditions
EasyTier Mobile / mobile-result (push) Blocked by required conditions
Nix Check / check-full-shell (push) Waiting to run
ohos / pre_job (push) Waiting to run
ohos / ohos (push) Blocked by required conditions
EasyTier Test / pre_job (push) Waiting to run
EasyTier Test / Run linters & check (push) Blocked by required conditions
EasyTier Test / Build test (push) Blocked by required conditions
EasyTier Test / Test (easytier) (push) Blocked by required conditions
EasyTier Test / Test (three_node) (push) Blocked by required conditions
EasyTier Test / Test (three_node::subnet_proxy_three_node_test) (push) Blocked by required conditions
EasyTier Test / test (push) Blocked by required conditions
The current malloc_conf only sets retain:false, which prevents jemalloc from retaining virtual memory but does not actively purge dirty/muzzy pages. On long-running EasyTier nodes this can lead to inflated RSS because freed arena pages are not returned to the OS promptly.

This tunes the jemalloc decay parameters:

background_thread:true — enables background GC threads so purge happens without blocking allocation hot paths.
dirty_decay_ms:1000 / muzzy_decay_ms:1000 — return unused pages to the OS within ~1s instead of holding them indefinitely.
2026-09-19 23:44:46 +08:00
KKRainbowandGitHub 0f3d8e4434 fix(core): keep peer liveness responsive under receive limits (#2590) 2026-09-19 08:22:12 +08:00
KKRainbowandGitHub e313ba8efb fix(wasi): reduce startup RSS, align ABI v4 and wire v3 (#2588)
* feat(easytier-go): accept data plane ABI v4

The core raised DATA_PLANE_ABI_VERSION to 4 in f26c2aa1 ("feat(wasi):
run EasyTier core on Cloudflare Workers and browsers") to advertise the
new guest exports easytier_data_plane_tcp_shutdown_write_submit and
easytier_data_plane_tcp_shutdown_write_result_take, which let a host
half-close guest TCP streams.

The Go host has no caller for half-close: net.Conn exposes only Close,
which already tears down both directions, so the v3 behavior is
preserved and no new plumbing is added. Without this bump, any artifact
rebuilt from current core source is rejected at instance creation with
"unsupported EasyTier data plane ABI version 4, want 3".

* fix(easytier-go): decode socket options wire v3, rebuild core artifact

38e2a621 ("refactor(ohos): 拆分 OHRS 包并按 socket 精细保护 VPN 流量")
raised the host socket options wire format from version 2 to 3: a
need_protect byte is appended after the purpose byte in TCP connect,
UDP bind, and TCP listen options, shifting bind_device one byte later.
The Go hostABI decoders were never updated, so every socket operation
from a HEAD-built core was rejected as "invalid options" and required
listeners failed to start.

Bump the accepted wire version to 3 and skip the need_protect byte in
all three decoders. The byte requests VPN socket protection, which only
Android-style VPN hosts can honor; on every other platform sockets are
already protected, so reading and discarding it is correct.

Regenerate the embedded core artifact and protobuf bindings from HEAD
(599e4eac) so the shipped artifact matches the Go host again. The proto
regeneration also picks up schema fields added since the last embed
(e.g. prefer_peer_relay).

* perf(easytier-go): release compiler garbage after host init

wazero's optimizing compiler allocates ~100MB of throwaway state on
the Go heap while compiling the embedded core. Go's runtime does not
return that memory to the OS after the initiating GC, so the process
retained the compilation peak for its entire lifetime: RSS sat at
~164MB before any instance or network activity.

Call debug.FreeOSMemory() once after the module is instantiated.
NewHost is a one-time initialization path outside the dataplane, so
the stop-the-world pass is safe here. Measured RSS after host.New
drops from ~164MB to ~64MB; no behavioral change.

* chore(easytier-js): bump toolchain dependencies

- vitest 2.1.9 -> 3.2.7 (all three packages)
- esbuild 0.25.9 -> 0.28.2 (browser bundler)
- wrangler 4.114.0 -> 4.134.0 (cloudflare + web example)
- @cloudflare/workers-types 5.20260724.1 -> 5.20260917.1
- binaryen 131.0.0 -> 132.0.0 (JS bindings only; the wasm build
  uses the standalone wasm-opt binary fetched by the build script)

Not bumped: typescript stays at 5.9.3 (latest 5.x; 7.0 is a major
jump not worth taking for this workspace), vite stays at 5.4.21
(web-example only; 5->8 spans three majors).

Verified with pnpm test (28 + 2 + 2 tests across runtime, browser,
cloudflare) and pnpm check (tsc + wrangler deploy --dry-run).
pnpm-workspace.yaml gained three minimumReleaseAgeExclude entries
recorded automatically by pnpm for freshly published versions.

* fix(easytier-js): align host wire format with core wire v3

The core socket options wire format moved to version 3 in 38e2a621
(need_protect byte after purpose). The JS websocket-host still
required version 2 and rejected every TCP bind from a HEAD-built core,
breaking browser port leases.

- websocket-host.ts: accept version 3, minimum length 49. The
  need_protect byte sits after purpose (offset 43) and is a no-op
  outside Android VPN hosts, so the decoder just skips it.
- websocket-host.test.ts: update the test encoder to emit v3.
- binaryen stays at 131.0.0 to match script/build-wasi-core.sh, which
  intentionally pins binaryen 131 for the Go-side embedded core.
  The npm binaryen package provides the wasm-opt binary used by
  build-wasm.mjs, so keeping both sides on the same version avoids
  divergent optimization output.
- pnpm-workspace.yaml: drop two stale minimumReleaseAgeExclude entries
  for @cloudflare/workers-types versions no longer in the lockfile.

* test(easytier-go): cover socket options wire v3 decoders

Direct unit tests for decodeTCPConnectOptions, decodeUDPBindOptions, and
decodeTCPListenOptions with wire v3 documents. Covers combinations of
socket mark, netns, bind device, and local address presence, plus
rejection of wire v2.

Resolves review feedback on PR #2588.
2026-09-19 00:13:47 +08:00
KKRainbowandGitHub 7853ec8685 fix(windows): reliable service auto-start on boot and start-on-boot GUI entry (#2579)
* fix(service): restart windows service indefinitely on failure

The windows service was installed with only the AutoStart start type
and no SCM failure actions configured. When the service failed during
boot (e.g. network not yet ready, config load error), it reported
SERVICE_STOPPED with a non-zero exit code and stayed stopped until
started manually, which is reported as no auto-start after reboot in
issue #1771.

Configure failure actions on install and update when restart is not
disabled:

- three Restart actions (1s/5s/10s delay) with reset period Never;
  the SCM repeats the last action once the failure count exceeds the
  actions array, so restarts are retried indefinitely
- set fFailureActionsOnNonCrashFailures so exits that report
  SERVICE_STOPPED with a non-zero exit code (the error path in
  win_service_event_loop) are also treated as failures; manual stops
  still exit with 0 and do not trigger a restart

Also exit the service process with a non-zero code after reporting the
error status. Without it the process stayed alive after reporting
SERVICE_STOPPED and was only counted as failed after the SCM
force-killed it, adding roughly 30s of dead time to every retry cycle.

This matches the systemd path, which already generates Restart=always
with StartLimitIntervalSec=0. The --disable-restart-on-failure option
now also clears previously configured failure actions on windows.

Verified on a real windows host: crash failures and reported-error
failures both restart with the configured 1s/5s/10s cadence
indefinitely, manual stops are not restarted, and
--disable-restart-on-failure clears the actions.

* feat(gui): add start-on-boot menu entry pointing to service mode

Issue #1771 reports that users cannot find how to make EasyTier start
on boot. Auto-start is provided by service mode, but the GUI offered
no entry named after it, so the connection was hard to discover.

Add a "Start on Boot" item to the settings menu. It opens the mode
dialog with service mode preselected and shows an info message
explaining that enabling service mode registers EasyTier as a system
service that starts automatically at boot and keeps running in the
background.

When the dialog is opened with service mode preselected, the mode
watcher in ModeSwitcher can run before the default config/log dirs
have been resolved, leaving the fields empty and failing validation on
save. Fill them from the resolved defaults after mount in that case.

Add mode.autostart / mode.autostart_hint strings to the cn/en locales
in frontend-lib.

* fix(cli): stop collecting the --core-args flag into the service args

InstallArgs.core_args was declared without an explicit `long`, so
clap treated it as a trailing positional argument instead of a named
option. Passing `service install --core-args --daemon ...` therefore
collected the literal "--core-args" token into the value, and the
installed service was registered with an invalid command line that
failed on every start (observed on a real windows host: the binPath
contained `easytier-core.exe --core-args --daemon ...`).

Declare it as a real option (`long` + `num_args = 1..`) while keeping
allow_hyphen_values and the trailing semantics: --core-args must be
the last option of install and consumes everything after it.

The bare-positional spelling (`service install --daemon`), the only
correctly-working form before, now fails with a clear "unexpected
argument" error; scripts written against it need to add the
--core-args prefix.

Add unit tests covering the flag, `=` and mixed forms.
2026-09-16 23:02:40 +08:00
Luna YaoandGitHub 7223677264 cargo: upgrade (#2116)
update dependencies
2026-09-16 20:50:43 +08:00
KKRainbowandGitHub 5477d4bca2 fix(proxy): isolate TCP flows and recover KCP control loss (#2569)
* fix(proxy): distinguish TCP flows sharing source ports

Key TCP NAT entries by source and destination so connections sharing a
source port can reach different targets independently. Allocate a unique
translated source port for each flow and restore the original addresses
and ports on the return path.

Keep connection identity during accept and cleanup so a replaced flow
cannot lose its new mapping. Give each smoltcp proxy mode its own
listener port and retain the original destination for mapped subnets.

Include regression coverage for SYN retries, concurrent insertion,
stale cleanup, translated port wraparound and exhaustion, plus the
recorded cross-platform and six-mode traffic validation.

* fix(proxy): preserve half-closed streams and pending KCP accepts

Propagate EOF to the opposite writer while keeping reverse traffic
alive until both directions finish. This allows a server to reply after
reading request EOF and a client to send after reading response EOF.

Pin kcp-sys to b37ee660 for retained pending accepts, receive-buffer
draining before EOF, early FIN handling, atomic state cleanup, and
keepalives that start only after the handshake completes.

Cover both shutdown directions, empty requests and buffered transfers
in gateway tests and the TCP/KCP/QUIC kernel/smoltcp integration matrix.
Include the half-close validation record and its remaining loss limits.

* fix(proxy): enable backward-compatible KCP control recovery

Pin kcp-sys to 26853356 and update both the root and OHOS workspace
lockfiles. The dependency negotiates reliable control per connection
while preserving legacy behavior when either endpoint lacks support.

New peers recover lost handshake and FIN control packets, acknowledge
FIN without closing the reverse writer, and retain bounded close state
for duplicate packets. Queue saturation cannot block reliable stateless
responses. Compatibility tests retain one historical baseline.

Include the protocol design and validation records for deterministic
loss, real old endpoints, three platforms, the full integration matrix,
and proxy traffic. Preserve mixed-version failures and the documented
minor scheduling issue rather than claiming legacy loss recovery.

 Validate the OHOS workspace with --locked: 12 library tests and the
binding test build check pass, along with formatting.
2026-09-16 12:50:08 +08:00
MRKandGitHub 286e0f4a0d fix(proxy): do not wrap TCP packets destined to own virtual IP (#2572)
The wrapped TCP proxy (KCP/QUIC) claims every SYN emitted by the local host,
including packets addressed to the node's own virtual IP. Those packets were
marked for the wrapped path and self-delivered, then the wrapped destination
lookup failed ("no peer found for wrapped TCP dst"). This broke local access
to the node's own virtual IP on systems that route own-address traffic through
the tun (macOS), and, with the proxy enabled on both sides, also inbound
wrapped flows to a macOS node, because the receiving side dials its own
virtual IP locally.

Skip the wrapped path when the SYN destination equals the local virtual IPv4.
Feed the NIC filter context the instance virtual IPv4 (snapshot.virtual_ipv4)
instead of the smoltcp fake inet so the check works with smoltcp enabled too;
the net-to-net guard is unaffected because it only runs when smoltcp is off,
where both values are identical.

Add a regression test for the KCP and QUIC transports.
2026-09-15 14:37:47 +08:00
KKRainbowandGitHub dd013e6a2a fix(acl): prevent bidirectional ping from bypassing inbound drop rules (#2570)
* fix(acl): match ICMP replies in outbound allow records

Only outbound echo requests with code zero create ICMP response records.
Require inbound complete packets and first fragments to be echo replies
with code zero before using those records, so reverse echo requests
remain subject to inbound ACL rules.

Recognize ICMP fragment tails without reading their payload as an ICMP
header. Allow them to use existing address and protocol records without
creating new records. Walk IPv6 extension headers to locate ICMPv6 and
avoid inferring an unknown tail protocol from its payload.

Add IPv4 and IPv6 unit coverage for message direction, code, truncation,
and fragment parsing. Add a three-node regression for bidirectional
requests and ordinary and fragmented replies under inbound default-drop.

Fixes #2545

* fix(acl): keep IPv6 parse failures subject to rules

Retain source and destination addresses when an IPv6 extension header
is truncated, exceeds the payload, or repeats a fragment header. Treat
these packets as unspecified protocol instead of entering the global
parse-failure allow path. Apply the same handling to truncated transport
headers after an extension header.

Classify non-ICMPv6 fragment tails as unspecified so their data cannot
be parsed as TCP/UDP ports or create temporary response records. Keep
ICMPv6 tail authorization through existing response records.

Exercise the ACL entry point with malformed headers and short and long
fragment tails under default-drop and default-allow policies, including
port allow rules that must not match tail payload bytes.
2026-09-15 10:03:15 +08:00
KKRainbowandGitHub c96b6c1961 fix(web): harden managed config sync between console and clients (#2567)
* fix(web): fence managed config runtime reconciliation

Keep runtime reconciliation tied to the currently authorized session so
stale connections cannot mutate a replacement session runtime.

Accumulate only contiguous dirty IDs and load their latest SQLite state.
Require the applied revision to match the earliest Patch base and the
persisted revision to match the latest target. Otherwise, reconcile the
full desired state.

Use separate runtime-state and config-cache epochs. Managed updates can
reuse observed configs; direct mutations invalidate them. Update sync
documentation to match.

* fix(web): interrupt validation retry on state changes

Track meaningful validation state changes separately from periodic dirty signals. Applied revision changes wake a failed validation immediately, while heartbeat-driven revalidation retains the retry backoff.

Treat Notify as a wake-up hint and recheck the state-change epoch after every wake so stored permits and periodic heartbeats cannot cause retry storms.

* fix(web): retry unconfirmed connected webhooks

Retry node-connected webhook delivery on retryable errors with a
short 100ms/500ms backoff and give up immediately on non-retryable
errors. Re-check that the session still owns the connection before
every attempt and before recording the delivery, so a replaced
session can no longer record a stale connected binding.

* fix(web): fence disconnects by session ownership

Return whether session removal actually removed the current route owner, and emit disconnected only for that owner. Replaced sessions can no longer invalidate a newer connected route.

* fix(web): hot-patch managed hostnames

Include hostname changes in the hot-patch path instead of falling
back to a full restart. When a full overwrite run is required and
the desired config has no hostname, inherit the current runtime
hostname so an unmanaged value survives until it is explicitly
cleared.

Read back the runtime config after an overwrite run and verify it
converged instead of assuming the desired state was applied.

* fix(web): retry transient runtime reconciliation failures

Keep the per-session managed runtime reconciliation worker alive when a
single database round fails. Retry from the next heartbeat so persisted
managed revisions can still converge after restart-time contention.

Reserve terminal worker shutdown for destroyed session or storage state,
and cover recovery after a transient revision read failure.

* fix(web): accept omitted hostname after runtime apply

Release 2.6.4 omits hostname from config readback when it matches the device hostname. Trust a successful hostname mutation only when the returned field is absent, while continuing to verify every other field and rejecting explicit mismatches.

* fix(web): ignore unmanaged runtime device names

Windows release 2.6.4 generates a random interface name when the managed config leaves dev_name empty. Exclude that runtime-owned value from reconciliation unless the desired config explicitly sets a non-empty device name, preventing endless overwrite restarts.

* feat(web): report failed network instances to console

Expose stopped Core instances with startup errors in heartbeats.

Merge Core failures with direct managed-run RPC failures in easytier-web.

Send failed instance IDs during token validation without error text.

Prune local run failures when managed configs are deleted.

* fix(web): distinguish unknown runtime application state

Track whether the current session has observed its applied revision
separately from the optional revision value. Report this fact through
validate-token so Console can preserve application state across
receiver restarts while recognizing deliberate pending mutations.

* feat(web): configure heartbeat timing from server

Heartbeat responses now provide the interval and RPC timeout.

Legacy servers use local defaults and remote values are clamped.

Web configuration and session receive timeout follow the policy.

* fix(web): reject inactive control sessions

Route control RPCs by machine id only to sessions whose RPC manager
is still running, so a session that has been stopped or replaced
can no longer receive control traffic addressed to the device.

* fix(core): filter network info before collection

When a collect-network-info request names specific instances,
collect those instances only instead of collecting every instance
and filtering the result afterwards, so unrequested instances no
longer run per-collection work on every request.

* feat(web): enable focused runtime diagnostics

Enable easytier-web info logs by default while preserving explicit log configuration. Record startup settings, session lifecycle, failed instance changes, webhook queue and request latency, and managed runtime operation timings for production diagnosis.

* fix(web): preserve managed revision across reconnects

Keep one runtime identifier for each Core WebClient lifetime.

Reuse its managed runtime state after transport reconnects.

Retain applied revisions and reconcile hints while disconnected.

Preserve runtime epochs so stale work cannot mark a revision applied.

Reject stale sessions from reclaiming routes after reconnect.

Core or Web restarts and legacy clients still use unknown state.

Immediately revalidate a restored revision after authentication.

Document local management RPC drift as an accepted trade-off.

This lets Console converge without waiting for periodic validation.

* fix(web): satisfy clippy across managed config sync tests

Scope managed runtime guards to blocks in runtime revision tests so
no std MutexGuard is held across await points, return the applied
revision directly instead of through a let binding, and pass
WebhookValidationInput to request_heartbeat_validation instead of
expanding it into eight separate arguments.

* fix(core): stop reporting failed instances as running in heartbeats

A stopped instance with a startup error appeared in both
running_network_instances and failed_network_instances, so the
server treated it as running and never re-ran its managed config.
Exclude failed instance ids when building the running list so the
reconciler restarts them.

* fix(core): close missed-wakeup race in instance state changes

wait_for_change created the Notified future before reading the
generation but only registered it when awaited. A change landing in
between fired notify_waiters with no registered waiter and delayed
the heartbeat by a full interval. Enable the future before reading
the generation so every change wakes a waiting heartbeat.

* fix(web): address review findings

Fence webhook validation and connection transitions against stale
state, redact credentials from default-level logs, and stabilize
runtime reconciliation:

- Record connected bindings only while the session still owns the
  machine route, and skip disconnect compensation once a replacement
  owns the route so a stale disconnect cannot revoke it.
- Discard webhook validation results when the change epoch moved
  during the HTTP round, so a stale rejection cannot invalidate the
  current session.
- Drop user_token fields from info and warn logs that became
  visible with info-level defaults.
- Restore a hostname omitted by the 2.6.4 readback into the cached
  runtime config after a successful mutation, so later rounds stop
  re-sending the same hostname patch.
- Reconcile running web configs when no revision is tracked so
  legacy unrevisioned updates converge, and wake sessions for
  unrevisioned full updates instead of waiting for the next
  heartbeat.

* chore(go): regenerate web proto bindings for heartbeat fields

Add failed_network_instances, support_heartbeat_policy, and the
heartbeat policy response fields to the checked-in Go bindings.
Other proto packages are left as-is because their drift predates
this change.

* fix(web): redact user tokens from positional log arguments

Three runtime reconciliation info logs and the user lookup error
contexts printed user_token through format arguments, which the
earlier field-syntax redaction missed. The reconcile log now fires
every round for unrevisioned machines, so remove the token from
these messages as well.

* fix(web): fence stale validation and runtime reconcile rounds

Check webhook validation epochs while holding the session write lock,
so stale success and rejection responses cannot change session state.
Advance the runtime epoch for unrevisioned full config updates, and
exclude failed instances from heartbeat and RPC reconciliation lists
so stopped instances are restarted instead of repeatedly hot-patched.

Release test read guards before awaiting validation apply calls. Set
up the no-pending condition before asserting that an applied revision
is a no-op, and verify that its runtime epoch remains unchanged.

Validation: all 137 client_manager tests passed.

* test(credentials): cover P2P with active VPN portal

Model an admin and temporary credential peer connected as a foreign network through a public server with data relay disabled. Verify their direct connection can be replaced after a WireGuard portal client comes online.

* test(credentials): stabilize two-admins failover assertions

The two-admins non-reusable credential test could fail on slow
convergence: after dropping the winning peer it relied on a single
route sample passing a bare AND condition, then re-asserted the same
expectations through one-shot checks seconds later. A transient route
flap in that window (for example a briefly resurrected winner route
from stale conn info) turned a passing convergence into a hard assert
failure. This matches the 48.9s CI flake of
credential_non_reusable_across_two_admins_allows_only_one_peer
observed on 2026-08-12.

Changes:

- wait for bidirectional admin connectivity (AND) with a 20s budget
  before issuing the credential, instead of a one-directional OR
- replace the failover wait_for_condition with
  wait_stable_failover_visibility_on_admins, which requires three
  consecutive samples of loser-present and winner-absent on both
  admins within the same 60s budget and logs every sample
- enrich the stable-single-winner timeout message with per-admin
  visibility flags and elapsed time for triage

All existing contracts are preserved; only observation windows and
diagnostics change. Validated in the rust container: three passes at
normal speed (54.1s / 53.8s / 53.1s) plus one slow-convergence round
(172.7s) that would have raced the old one-shot sampling; it now
passes with failover samples logged. cargo fmt and clippy -D warnings
clean.
2026-09-13 01:13:28 +08:00
KKRainbowandGitHub e0bdb516b6 fix(quic): bind proxy packet checksum to packet number via ETQ1 version (#2565)
* fix(quic): bind proxy packet checksum to packet number via ETQ1 version

QUIC proxy connections die with quinn PROTOCOL_VIOLATION "unsent
packet acked" under bursty traffic with reordering, and the affected
peer pair keeps failing for every new connection until the source node
restarts.

Root cause: the custom crypto checksums the packet bytes but not the
packet number, while quinn decodes truncated packet numbers by
proximity to the largest received (RFC 9000 Appendix A). A 1-byte
encoded packet delayed beyond the +/-128 decode window is decoded as
a future packet number, still passes the checksum, gets ACKed, and
the peer aborts because it never sent that number. Real QUIC survives
this because the AEAD nonce is derived from the packet number, so a
misdecode fails authentication.

Fix: negotiate a custom QUIC version ETQ1 (0x45545131) for the proxy.
Connections on ETQ1 mix the packet number into the SeaHash checksum,
so an out-of-window misdecode fails authentication and the packet is
handled as ordinary loss. The mode is derived statelessly from the
negotiated version in QuicSession and ServerConfig::initial_keys.

Compatibility: the proxy endpoint accepts both ETQ1 and version 1.
NatDstQuicConnector dials ETQ1 first; on ConnectionError::VersionMismatch
from a legacy peer it retries with version 1 and remembers the peer in
legacy_version_peers to skip the rejected version afterwards. The
quic:// tunnel keeps version 1 only.

Verified with 13 docker nodes under netem jitter and bursty iperf
load: the previously-poisoned pair survived 16 minutes on ETQ1 with
zero violations while all legacy-version pairs kept dying; mixed
new-to-old and old-to-new connections work.

* fix(quic): extend the ETQ1 checksum fix to the quic:// tunnel

The quic:// tunnel shares CryptoKey with the proxy, so after the proxy
moved to ETQ1 the tunnel still carried the unsent-packet-acked exposure.
Make endpoint_config() dual-version so tunnel listeners accept both
ETQ1 and legacy peers, and dial ETQ1 first in upgrade_connected with a
transparent fallback to version 1 on VersionMismatch, via a shared
connect_with_etq1 helper. The proxy keeps its hedged dialer with the
per-peer legacy memory; tunnel connections are established once per
session, so the fallback there costs a single extra round trip.
2026-09-11 23:33:46 +08:00
a7383114d0 fix(core): do not allocate DHCP IP without an assigned peer IPv4 (#2551)
Fixes the DHCP allocator treating its hard-coded fallback
 subnet as an interface lease when no peer IPv4 has been 
observed. With DHCP enabled and no devices carrying a 
virtual IPv4, the allocator now waits instead of pulling 
10.126.126.0/24 onto the TUN interface.

Behavior preserved when an explicit allocator subnet is 
supplied or when peers with assigned IPv4 exist.

---------

Co-authored-by: 225284228a-droid <225284228a-droid@users.noreply.github.com>
Co-authored-by: Codex <codex@users.noreply.github.com>
2026-09-11 23:06:18 +08:00
KKRainbowandGitHub 19e5c49ba3 chore: bump version to 2.7.0 (#2566)
* chore: bump version to 2.7.0

Update version strings across the workspace:
- crate versions and internal dependency requirements for
  easytier, easytier-core, easytier-proto, easytier-web,
  easytier-gui, and easytier-mini, plus Cargo.lock
- GUI package.json and tauri.conf.json
- Magisk module.prop
- default release/docker workflow tags (v2.7.0)

* fix(ohos): sync easytier-ohrs Cargo.lock with bumped workspace versions

The ohos workflow builds easytier-ohrs with --locked, so its
lockfile must record the new 2.7.0 versions of the easytier,
easytier-core, and easytier-proto path dependencies.
2026-09-10 22:38:24 +08:00
KKRainbowandGitHub 974c3270e8 feat(magisk): add module WebUI configuration (#2563)
* feat(magisk): add module WebUI configuration

Reuse the existing config generator in KernelSU-compatible module
managers. Validate and atomically persist TOML through a module helper
before restarting EasyTier, and package the generated assets in CI.

Closes #1915

* fix(magisk): preserve existing WebUI configuration

Merge form-managed fields into the original TOML so advanced module settings remain intact. Resolve the running core by its exact executable path before restart.
2026-09-10 20:13:21 +08:00
KKRainbowandGitHub 3d0c9c3ca5 chore(go): use lowercase module import path (#2560)
* chore(go): use lowercase module import path
* ci: scope checks for Go module changes
2026-09-10 15:13:55 +08:00
KKRainbowandGitHub 86d942ec8c docs: add security reporting policy (#2561)
* docs(security): add private reporting policy

Document supported versions and route vulnerability reports through GitHub's private advisory workflow.

Add English and Chinese responsible-use notices to the READMEs.

Closes #2544

* ci: skip unrelated pull request builds

Use pull-request-aware path filtering for required Core, GUI, Mobile, and Test workflows so they still publish required check contexts without launching expensive jobs for documentation changes.

Limit the optional OHOS pull request workflow to relevant paths.
2026-09-10 12:30:49 +08:00
Zhengqi ZhangandGitHub 44a0a17f68 fix(android): avoid duplicate foreground notifications (#2559) 2026-09-10 11:39:34 +08:00
KKRainbowandGitHub 993640b1dd fix(config): normalize [secure_mode] when loading TOML config files (#2562) 2026-09-10 08:56:57 +08:00
Mg PigandGitHub 0fba5769a3 feat(web): add URL path support for listener addresses (#1967) 2026-09-09 22:55:44 +08:00
38e2a621bb refactor(ohos): 拆分 OHRS 包并按 socket 精细保护 VPN 流量 (#2543)
* refactor(ohos): split facade feature and kernel crates
* feat(ohos): protect transport sockets individually
* fix(ohos): keep local proxy subnets off tun
* fix(ohos): expose valid config enum values
* refactor(ohos): finalize reusable core boundary
* test(ohos): verify split package contracts

* fix(port-forward): support wildcard userspace listeners
Keep the existing Host listener intact while adding a DataPlane listener for force-smoltcp IPv4 wildcard rules. Keep literal loopback destinations on the local Host path instead of exporting them through an exit node.

* chore(ohos): refresh split workspace lockfile
* fix(socket): normalize Windows raw socket handles
* refactor(socket): carry VPN protection through host bind options
* refactor(socket): simplify protection defaults and TUN ingress
* refactor(socket): consolidate native protection and socket creation
* fix(socket): protect outbound UDP paths
* fix(socket): preserve VPN routing for RPC listeners

---------

Co-authored-by: FrankHan <frankhan@FrankHans-Mac-mini.local>
Co-authored-by: KKRainbow <443152178@qq.com>
2026-09-09 22:12:47 +08:00
MiaM1kuandGitHub b1f87f025b feat(easytier-go): expose CreateInstanceTOML and ShowNodeInfo (#2557) 2026-09-08 20:27:52 +08:00
KKRainbowandGitHub e972307510 feat(upnp): inline the IGD client (#2555)
* feat(upnp): inline the IGD client

Replace igd-next with the subset EasyTier uses for SSDP discovery,
device description parsing, and SOAP port-mapping requests.

Route SSDP and HTTP connections through EasyTier's existing socket
factories so platform-specific socket policy remains centralized.
Preserve dynamic service types, XML escaping, response limits, and
request timeouts from the upstream implementation.

Keep the upstream MIT license and retain mapping inspection only for
integration tests.

* chore(ohos): refresh lockfile after IGD migration

Update the independent OHOS workspace lockfile for the changed EasyTier dependency set so Cargo --locked accepts it. Remove the stale igd-next package and its now-unused transitive dependencies.
2026-09-08 00:32:03 +08:00
57afa290a4 feat(acl): enhance ACL handling to support JSON and TOML formats (#2540)
* feat(acl): enhance ACL handling to support JSON and TOML formats

---------

Co-authored-by: bright <nako_ruru@sina.com>
2026-09-06 22:33:44 +08:00
KKRainbowandGitHub abb9fd7e1a fix(core): reset foreign networks when the event handler lags (#2550)
A lag used to terminate the event handler and detach the network entry
without closing its live peer connections. Those peers stayed connected
to an untracked entry, so the network could no longer recover.

On Lagged, serialize teardown with peer admission using the entry lock,
atomically detach the current entry, remove its reverse-index records,
and close every peer. A secret-verified peer can then reconnect and
create a fresh entry.

Admission now revalidates the entry before and after adding a
connection. Credential-authenticated peers may only reuse an existing
entry, and that check shares the manager lock with entry insertion.

Each entry tracks its indexed peer IDs. Teardown can therefore clear
skipped PeerRemoved records without scanning every network or leaving
stale reverse mappings.
2026-09-06 22:32:24 +08:00
fe764ce45c ci: add SignPath test signing workflow (#2549)
* ci: add SignPath test signing workflow

* ci: preserve existing workflow permissions

---------

Co-authored-by: Your Name <you@example.com>
2026-09-06 15:15:05 +08:00
KKRainbowandGitHub f26c2aa147 feat(wasi): run EasyTier core on Cloudflare Workers and browsers (#2548)
* fix(core): normalize secure keys for TOML instances

* feat(wasi): run core behind Cloudflare WebSockets

Introduce the Cloudflare Worker WASI host that runs the EasyTier core
behind host-upgraded WebSockets.

- Worker package scaffold (wrangler Durable Object, build-wasm script,
  vitest config) and core-runtime/websocket-host/data-plane runtime.
- WASI host WebSocket tunnel ABI (imports, adapter, runtime exports)
  with bounded receive memory and bounded admission queue.
- Route host sockets through the portable listener plan
  (HostListenerRegistration, listener queue, admission handler split).
- Build the WASM guest with the aes-gcm feature so secure peer
  sessions have their cipher available.

* feat(wasi): add outbound browser client runtime

Add the outbound-only WASI runtime and browser connector host so
browser pages can dial EasyTier peers through WebSocket relays.

- CoreConnectivityMode::{OutboundOnly, InboundOnly} gating for
  listeners, discovery, and direct connectivity modules.
- ExternalTunnelConnector plumbing through composite/connector_host/
  manual for browser WebSocket dials.
- Browser/Node smoke entries with shared helpers
  (smoke-shared.ts).

* feat(wasi): extend browser data plane with TCP half-close

Add the data-plane pieces the browser runtime needs for full-duplex
TCP streams behind host WebSockets:

- Guest TCP shutdown_write operation with submit/take ABI pair
  (DATA_PLANE_ABI_VERSION 3 -> 4) and smoltcp half-close support.
- Worker data-plane TCP listener/stream plumbing and core-runtime
  listener registration.
- Unit coverage for the new session ops and listener wiring.

* refactor(wasi): make host tunnel ABI transport-neutral

Replace WebSocket-specific core and WASI boundaries with a
message-oriented Host Tunnel interface. Keep WebSocket framing and text
rejection in the Cloudflare host while preserving payload boundaries,
ownership, cancellation, backpressure, and EOF behavior.

Rename feature flags and guest imports and exports to the Host Tunnel
ABI. Update both Worker profiles, tests, and architecture documentation.

* feat(web): split WASI hosts into publishable npm packages

Extract the shared JSPI, WASI, Host Tunnel, and data-plane runtime
into @easytier/runtime. Keep ABI handles, guest memory, TOML, and
operation broker details behind its adapter entry point.

Add typed, auto-starting @easytier/browser and factory-based
@easytier/cloudflare packages. Ship a matching Wasm profile with
each platform package and validate its capabilities before packing.

Persist Cloudflare instance identity in Durable Object storage,
centralize WebSocket admission ownership, and add package-level
coverage for the public interfaces.

* fix(web): make public packages portable

Embed the browser Wasm artifact in the published JavaScript entry
point. This lets esbuild consumers bundle the package without an asset
loader or a copied file.

Return Cloudflare's nominal Durable Object base type and document the
named subclass export required by generated Wrangler bindings.

* docs(web): add public package walkthrough

Expand both package READMEs with installation, configuration, local
validation, health checks, and deployment instructions.

Add a standalone Vite and Wrangler example that imports only the
public Browser and Cloudflare entries. Generate Worker bindings from
configuration and keep local secrets outside version control.

* chore(go): import EasyTier Go host

Add the standalone Go host runtime as a monorepo subtree without
carrying its development branch ancestry.

Preserve its API, tests, examples, generated protobuf bindings, and
embedded WASI artifacts.

* refactor(hosts): colocate Go and JavaScript runtimes

Move the browser, Cloudflare, shared runtime, and web example into
the easytier-js subtree. Update workspace metadata, build paths, and
documentation for the new layout.

Adopt github.com/EasyTier/EasyTier/easytier-go as the Go module path.
Resolve artifact and protobuf generation from the enclosing monorepo.

* build(web): isolate JavaScript host workspace

Keep public browser and Cloudflare packages outside the legacy frontend
workspace so root installs and cross-platform builds do not pull workerd.

Make each package build generate its required WASI artifact from a clean
checkout. Add a dedicated workflow that runs the same install and check
commands documented for contributors.

Move JavaScript dependencies into a scoped lockfile and restore the root
workspace lockfile to its pre-host state.
2026-09-06 13:35:02 +08:00
Zhengqi ZhangandGitHub 56be71c7f9 fix(android): keep VPN running when pressing back (#2546) 2026-09-06 11:21:38 +08:00
Zhengqi ZhangandGitHub f19bcfb400 feat(android): add VPN quick settings tile (#2511)
add an Android Quick Settings tile for starting and stopping EasyTier VPN networks
persist tile actions until the Tauri frontend is ready, so cold-start clicks are not lost
add the standard Quick Settings preferences activity alias so long-pressing the tile opens EasyTier
keep network and VPN lifecycle ownership in the existing frontend reconciliation flow
2026-09-05 18:08:20 +08:00
KKRainbowandGitHub 164e2db6ae feat(core): expose WireGuard client traffic metrics (#2537)
* feat(core): expose WireGuard client traffic metrics

Count successfully accepted upload and delivered download packets
per VPN portal client. Keep counters stable across session reconnects
and export them through the existing Prometheus statistics endpoint.

If a client is removed by a concurrent config update while one of its
sessions is still starting, drop that session instead of panicking:
the release profile aborts the whole process on panic.

* ci(core): pin cargo-zigbuild to 0.23.2

cargo-zigbuild 0.23.3 (released today) passes
-mcpu=generic+v6+strict_align to zig cc when building jemalloc for
arm-unknown-linux-musleabi; zig 0.16.0 rejects that mcpu value, so the
linux-arm job fails and fail-fast cancels the rest of the build matrix.
Pin the tool to the last working version until zig is bumped.
2026-08-29 20:52:54 +08:00
KKRainbowandGitHub 25f6e2dc5e feat: stabilize mobile runtime and VPN portal (#2536) 2026-08-29 13:40:56 +08:00
KKRainbowandGitHub 4a10d1c2b9 feat(mobile): add embedded runtime and managed network updates (#2532)
* feat(mobile): add embedded iOS runtime API

Add a thin panic-safe C ABI crate for embedding no-TUN instances
on iOS. Expose lifecycle, status, JSON-RPC, string ownership, and
error handling.

Build device and simulator XCFramework static libraries on macOS.
Add exact named-instance deletion to the iOS and Android wrappers.
Cover wrapper lifecycle and the port-forward patch flow on host
targets.

* fix(gateway): recover TCP port-forward listeners

Release an unusable TCP port-forward listener after an accept
failure. Retry binding until the forward is cancelled. Keep the old
listener released while rebinding so mobile sockets can recover.

Expose opt-in iOS diagnostics for listener and connection events.
Trace configuration removal and adapter shutdown. Add tests for
recovery, release-before-rebind, and cancellation.

* feat(web): persist incremental managed config patches

Add a revision-CAS PATCH contract for managed configs while keeping
the existing Full PUT path for compatibility and recovery.

Apply Full and Patch mutations with their revision in one SQLite
transaction. Reject ownership conflicts and invalidate revisions on
alternate web-owned writes.

Document limits, failure semantics, rollout order, and verification.
Cover delta updates, conflicts, idempotency, and transaction rollback.

* feat(web): apply managed config patches to live sessions

Carry Patch fences and touched instance IDs into live sessions.
Reconcile only those instances when the applied revision matches the
Patch base. Fall back to Full reconciliation for gaps and restarts.

Invalidate the applied revision around every direct runtime mutation.
Fence revision advancement with the runtime cache epoch so stale
reconcile rounds cannot overwrite a newer invalidation.

Require deletion responses to confirm each requested instance before
advancing the revision. Raise the managed PUT and PATCH body limit to
32 MiB and return typed conflicts for publisher recovery.

* fix(core): retry transient accepted TCP errors

Keep TCP tunnel listeners alive when an accepted socket fails during
upgrade with a retryable connection-state error.

Share the retryable I/O classifier with the socket listener. Cover a
rejected connection followed by success and propagation of permanent
errors.

* feat(core): add internal Peer Relay edge projection

Derive the local advertised OSPF row from physical adjacency and transport-authenticated credential relay coverage. Keep full local adjacency only in the temporary SPF snapshot so direct destinations retain a fallback route.

Leave Peer Relay disabled at the public configuration seam. A follow-up change can expose the preference without coupling route projection to credential reauthorization.

feat(config): expose Peer Relay routing preference

Add prefer_peer_relay to public protobuf, TOML, management patch, and
hosted runtime surfaces.

Read the preference from live peer context so runtime config updates take
effect. Refresh authenticated peer metadata when the option is enabled.

Cover dynamic enable and disable in a five-node, dual-admin credential
topology, including forwarded relay coverage and local fallback.
2026-08-28 00:43:26 +08:00
Xingjian DuandGitHub abf03ca521 dns: timeout system resolver before hickory fallback (#2310)
Bound libc DNS before falling back to Hickory so the manual
connector's two-second resolution budget cannot expire first.

Keep at most one system lookup in flight because Tokio cannot cancel
a blocking getaddrinfo call. This prevents reconnects from exhausting
the blocking pool while allowing the system resolver to recover.

Apply the policy to process-default and namespace-aware resolution,
and cover timeout, retry, and success behavior with deterministic
tests.
2026-08-27 16:22:50 +08:00
KKRainbowandGitHub 67f2270ee7 fix(credential): trust pinned admin keys on first connect (#2528) 2026-08-26 23:59:13 +08:00
KKRainbowandGitHub 81487c363a fix(web): fence routes to the current session (#2522)
* fix(web): publish routable sessions before connect events
* fix(web): fence routes to the current session
2026-08-24 09:49:27 +08:00
3b2aadde8e chore: enforce LF for shell scripts and ignore Android .gradle cache (#2520)
Add a root .gitattributes pinning *.sh to LF so shell scripts stay
executable after a checkout on Windows with autocrlf enabled; without
it every .sh lands in the working tree as CRLF and fails when run
under WSL/bash.

Also ignore tauri-plugin-vpnservice/android/.gradle, which Gradle
regenerates on every sync.

Co-authored-by: bright <nako_ruru@sina.com>
2026-08-23 13:50:36 +08:00
b93e45a215 feat(acl): add runtime ACL replacement via TOML (easytier-cli acl set) (#2513)
Adds a new CLI subcommand to replace the entire ACL at runtime
from a TOML input (either inline or from a file). The command is 
`easytier-cli acl set <TOML|@path>`. 

Co-authored-by: bright <nako_ruru@sina.com>
2026-08-23 13:35:37 +08:00
e130e9e4c7 fix(gui): set HOME for macOS launchd service (#2509)
Co-authored-by: hezz <hezz@example.com>
2026-08-23 10:42:36 +08:00
KKRainbowandGitHub 4304b065cb core: allocate smoltcp listener sockets per SYN (#2519)
A half-open handshake consumed the only smoltcp listener socket, so
other clients were rejected until its timeout. Closed listener sockets
could also remain unusable after a network interruption.

Register logical TCP listeners with the reactor without preallocating
socket slots. Allocate one temporary smoltcp socket for each new SYN;
repeated SYNs reuse the existing connection. Limit each listener to
sixteen pending handshakes or completed connections.

Batch ordinary ingress packets through smoltcp and scan listener state
once per batch. Before admitting a SYN, advance timers, reclaim stale
pending sockets, and flush queued packets. Process only a newly
allocated socket's SYN separately to preserve packet order.

Keep the global tuple lookup off unmatched and full-listener rejection
paths. Promote established connections to the normal timeout, reclaim
closed connections, and transfer accepted sockets to streams.

Keep one long-lived logical listener in SmolTcpStack and cover
concurrency, retransmission, timeout recovery, capacity, and cleanup in
tests.
2026-08-23 10:41:49 +08:00
KKRainbowandGitHub 9f231ee76c refactor(credentials): centralize grant policy (#2517)
* refactor(credentials): centralize grant policy

Represent ACL groups, relay permission, proxy CIDRs, and reuse as one
internal credential grant shared by generated, imported, managed, and
attached credentials. Normalize proxy CIDRs at construction while
preserving the flat credential storage schema.

Reuse one managed credential adapter for protobuf/TOML projection and
patching so defaults and future fields have a single mapping authority.

* fix(credentials): normalize grants loaded from storage

Run persisted grants through the same CIDR normalization used by new and
managed credentials. Reject invalid stored CIDRs through the existing
storage-unavailable path and include the credential ID in the error.

Cover whitespace migration and invalid legacy data with regression tests.
2026-08-22 23:55:29 +08:00
KKRainbowandGitHub 3fe427bc99 feat(credentials): manage declarative credentials through TOML (#2515)
* feat(credentials): manage declarative credentials through TOML

Make managed credentials part of the canonical TOML configuration and
load them before peers can authenticate.

Reuse ConfigRpc hot patches to durably replace the configured credential
set without restarting the instance. Serialize credential mutations so
base, managed, and ephemeral keys cannot race into conflicts.

Remove the managed overlay file format, digest protocol, capability
negotiation, force reconciliation, and database CAS machinery. Redact
credential secrets from debug output and management events. Write
credential-bearing files atomically with private permissions.

* fix(core): release JoinSet reapers with their owners

Pass weak task-set references into background reapers so they cannot
retain the JoinSet they are meant to collect. This lets stale smoltcp
bridge tasks terminate when an IPv4 generation is replaced.

Add ownership and TCP generation-replacement regressions covering the
production port-forward failure.
2026-08-22 16:30:51 +08:00
KKRainbowandGitHub 8794e12a26 feat(vpn): hot add/remove WireGuard portal clients without restart (#2514)
* feat(vpn): hot add/remove WireGuard portal clients without restart

WireGuard portal clients were frozen at instance construction: the
engine slot maps, host key table, and PortalModule state were all
immutable after startup, so any client change required recreating the
whole instance and dropping every established session.

Wire dynamic client management through the existing config-patch
channel (ConfigRpc.patch_config -> apply_config_patch), following the
same pattern as connectors, port forwards, and proxy networks:

- proto: InstanceConfigPatch gains repeated VpnPortalClientPatch
  (Add/Remove/Clear by client name)
- engine: slot maps move under an RwLock with a free-index allocator;
  add_client/remove_client recycle indices, mark removed slots retired,
  and expire active sessions so Core tears down the attached peer via
  the regular channel-close path (credential revocation and disconnect
  events included); untouched clients keep their sessions intact. The
  retired flag is re-checked under the session lock so a datagram that
  races with removal cannot resurrect a session
- host: WireGuardPortalHost derives keys deterministically per name
  (HKDF), keeps a mutable client table for render_client_config, and
  forwards updates to the live engine; changed clients are re-added so
  they re-handshake into a fresh generation with the new virtual IP or
  groups
- PortalModule: client set, statuses, and session locks become shared
  mutable state; run_session resolves clients from the shared map at
  accept time; update_clients() validates against a caller-supplied
  runtime snapshot. An empty client set is legal in every lifecycle
  stage, so clearing all clients never produces a configuration that
  fails instance recreation
- config_patch: apply_vpn_portal_client_patches mutates the candidate
  TOML; the sub-patch runs last and is deep-validated and hot-applied
  before the candidate commits, so a rejected client set leaves neither
  the shared model nor the live portal changed, and validation sees the
  fully patched state including routes and node IPv4 from the same
  request. Rejects patches when no portal is configured or a removed
  client does not exist
- cli: vpn-portal add-client/remove-client/clear-clients subcommands

Tests: engine index recycling, module update validation/state/host
notification, TOML patch application, and a three-node integration test
that adds a second WireGuard client live, removes the first while the
second stays online, and asserts rejected patches leave the shared
model unchanged.

* feat(web): reconcile WireGuard portal client edits as hot patches

The web console reconciles desired network config against the running
instance and patches it in place when possible. VPN portal changes were
not part of that: any client edit made the base configs differ, so every
save recreated the instance and dropped all established sessions.

Exclude vpn_portal_config from the base comparison and diff its clients
by name instead. Client add/remove/change now produces
VpnPortalClientPatch entries (removals first, changed clients as
remove+add) applied through the existing PatchConfig channel. Listener
identity changes (address or private key) and enabling or disabling the
portal still fall back to a full instance recreate, since those change
the listener lifecycle.

* feat(web/gui): map portal client patches to frontend RPC backends

Extend the RemoteClient seam with add/remove/clear VPN portal client
operations so frontend hosts can drive the same PatchConfig channel as
the CLI. There is deliberately no dedicated editing UI: the config form
stays the single editing surface (aligned with port forwards), and
these methods exist for programmatic and future use.

- web console: JSON proxy-rpc to ConfigRpcService.patch_config with
  VpnPortalClientPatch entries (pbjson string enum actions)
- desktop GUI: patch_vpn_portal_clients tauri command forwarding the
  same patch through the typed ConfigRpc client
2026-08-22 01:18:42 +08:00
KKRainbowandGitHub 62e4fd15e9 feat(vpn): multi-client WireGuard portal with attached peers (#2502)
* feat(peer): support protocol-agnostic attached peers

Add locally attached peers backed by independent, peer-level portable
managers and authenticated in-process ring connections. Carry trusted
connection provenance through packet admission so attached relay
privileges cannot be forged through packet headers.

Let every peer manager own ACL loading, sanitized policy updates, route
refresh, and runtime cleanup. In Secure Mode, grant attached identities
ephemeral credentials instead of sharing administrator and group secrets.

* feat(vpn): add reusable attached-peer portal runtime

Add a protocol-neutral portal runtime that converts authenticated client
sessions into attached EasyTier peers. Own per-client generations,
status, packet forwarding, address translation, and peer cleanup without
knowing the transport protocol.

Add transactional IPv4 source and destination rewriting with correct
IPv4, TCP, UDP, ICMP, and quoted-packet checksum updates. Keep the old
production portal path temporarily active until the WireGuard adapter is
migrated in the next change.

* feat(wireguard): attach named clients through peer portal

Replace the monolithic WireGuard portal with a native adapter that owns
key derivation, UDP demultiplexing, reauthentication, roaming, and
bounded per-client packet queues. Hand authenticated sessions to the
generic portal runtime for peer lifecycle and IPv4 translation.

Move portal configuration into the core instance model, require a
dedicated server key, and preserve existing listener, CLI, and runtime
configuration behavior. Reject runtime address conflicts before
publishing shared configuration.

* feat(vpn): expose per-client portal status

Project configured clients and their runtime state through the portal
RPC, including generated client configuration, listener, peer identity,
endpoint, tunnel address, ACL groups, and errors. Keep private client
configuration out of the broad instance-info response and expose the
explicit RPC through the CLI and Tauri bridge.

* feat(vpn): add portal configuration to web clients

Expose WireGuard portal listener, key, client, ACL group, and runtime
status fields in the shared frontend library, Web dashboard, and Tauri
client. Preserve UUID and uint64 values across protobuf JSON
boundaries, keep dynamic client editor rows stable, and document the
portal workflow.

* test(vpn): cover multi-client and roaming WireGuard portals

Add two three-node integration tests for the WireGuard VPN portal.

The multi-client test connects two kernel WireGuard clients from
separate network namespaces, verifies per-client connectivity to mesh
nodes, and exercises cross-client traffic that runs the IPv4 source
and destination translation in both directions. A TCP echo exchange
through the portal additionally covers the TCP pseudo-header checksum
rewrite path that ICMP-only ping tests miss, and portal status
snapshots must report both clients online with distinct peer ids and
correctly learned tunnel addresses.

The roaming test swaps the client namespace address (delete the old
address, then add the new one) so the kernel WireGuard source cache is
invalidated and the client keeps sending under the same session from
the new source, exactly like a real network change. The portal must
update the client endpoint on the same peer id via the data path
(same generation, no re-handshake, no detach/reconnect) while
connectivity to mesh nodes is preserved.

Supporting changes: run_wireguard_client now takes an interface name,
and the shared namespace topology gains net_f (10.1.2.5) on the portal
bridge for the second client.
2026-08-21 10:59:05 +08:00
fanyangandGitHub 57eb6908f4 perf(recv): add try_recv fast path in recv_packet_from_chan (#2426)
Add a try_recv() fast path to recv_packet_from_chan(): if a packet is
immediately available, return it without parking the task. Only when
the channel is empty do we fall back to recv().await.

This benefits all callers of recv_packet_from_chan() including
start_peer_recv, virtual_nic, foreign_network_manager, and instance.
2026-08-19 00:16:38 +08:00
fanyangandGitHub f64af13424 fix(cli): preserve proxy CIDRs in route output (#2503) 2026-08-18 20:37:57 +08:00
Zhengqi ZhangandGitHub 35845c14e6 fix(android): reconcile VPN service after startup (#2491)
Summary
Reconcile the Android system VPN after the GUI has finished initializing the EasyTier core.
Retry reconciliation while network information or the virtual IPv4 address is not ready yet.
Serialize reconciliation work and de-duplicate concurrent VPN permission requests.
Root cause
On Android, the network instance can report that it has started before collectNetworkInfo exposes the instance state and virtual IPv4 address. The previous startup path treated that temporary state as a terminal failure, stopped VPN setup, and relied on another event to retry it. If no later event arrived, peers could connect successfully while the Android VpnService remained inactive until the user stopped and started the network again.

PR #1628 added polling for the DHCP-specific empty-IP case. The same race can occur earlier, while network information is still unavailable, and can also affect static-IP configurations.
2026-08-17 22:12:01 +08:00
ParkGardenandGitHub 282cd92f2c fix(Windows): 移除 wait_interface_show 的 10 秒硬超时,避免开机无网络时 TUN 接口未就绪导致实例停止 (#2500) 2026-08-17 19:13:14 +08:00
KKRainbowandGitHub 636390ec38 feat(peer): echo liveness probes on data traffic (#2497)
* feat(peer): echo liveness probes on data traffic

Advertise a liveness-echo capability during classic and Noise
handshakes. After a ping failure, tag outgoing peer packets with a
short probe token and accept only the matching echoed token as
round-trip proof.

Keep one ping request outstanding and coalesce scheduler triggers so
high traffic cannot reorder timeout results. Preserve one-way failure
detection because unrelated ingress never clears the loss counter.

* test(three_node): relax disconnect wait for sequential pingpong

proxy_three_node_disconnect_test assumed the old pingpong timing,
where overlapping pings failed fast and the connection closed well
inside the 11s wait (see the old [4, 9)s comment).

The liveness-echo change keeps one ping outstanding: each failure
now takes a full 2s timeout, so the fifth consecutive failure and
the connection close land at ~11s. Both proto variants timed out at
the 11s bound in CI. Widen the wait to 15s and update the timing
comment.
2026-08-15 00:21:07 +08:00
fanyangandGitHub 8c15941c44 Support configurable TCP STUN servers (#2314)
tcp_stun_servers explicitly controls TCP STUN servers.
If tcp_stun_servers is not configured, TCP STUN falls back to configured stun_servers.
If neither is configured, TCP STUN uses the built-in default TCP STUN list.
Empty lists explicitly disable the corresponding STUN server list.
Empty CLI/env overrides now clear existing configured STUN servers instead of appending nothing.
2026-08-13 09:56:44 +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
韩嘉乐andGitHub 16b666ad25 fix: route_update message is not lag (#2355) 2026-06-16 00:00:48 +08:00
Luna YaoandGitHub 8909e88484 do not panic when fail to parse flags (#2349) 2026-06-14 13:12:20 +08:00
Luna YaoandGitHub 5edc4cb1cd fix: remove quinn-plaintext (#2345)
Remove quinn-plaintext to fix connection errors caused
by different hash values ​​across platforms.

On x64, maintain compatibility with quinn-plaintext.
2026-06-14 01:21:35 +08:00
韩嘉乐andGitHub e7709f1cb5 [OHOS] feat: improve status manager (#2343)
* fix: improve status manager
2026-06-11 17:27:10 +08:00
韩嘉乐andGitHub c0f42ebe8c fix: improve log manager (#2329) 2026-06-07 23:32:22 +08:00
KKRainbowandGitHub 9d965cae64 Add FFI JNI JSON RPC bridge (#2326)
* Add FFI JNI JSON RPC bridge
* Add FFI instance list API
2026-06-07 17:48:58 +08:00
韩嘉乐andGitHub da28c8badc [OHOS] fix: 修复内存泄露问题,并重构日志管理,预防性修复数据库初始化异常问题 (#2328)
* fix: leak memory
feat: new log manager

* fix: fail to init db

* fix: fail to init db

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

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

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

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

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

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

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

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

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

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

This PR adds data plane APIs to easytier-ffi:

TCP Outbound:

- data_plane_tcp_connect
- data_plane_tcp_read
- data_plane_tcp_write
- data_plane_tcp_close

TCP Listener:

- data_plane_tcp_bind
- data_plane_tcp_accept
- data_plane_tcp_listener_close

UDP:

- data_plane_udp_bind
- data_plane_udp_send_to
- data_plane_udp_recv_from
- data_plane_udp_close

2. Key Changes

The main changes are focused on:

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

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

- Other files: Mostly pass-through logic.

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

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

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

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

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

* fix: make ohrs snapshot sync and config id validation safer
2026-06-02 22:40:48 +08:00
fanyangandGitHub 00957e5f9d feat: add Docker healthcheck (#2279) 2026-05-24 23:12:51 +08:00
fanyangandGitHub bfa3383aaa chore: update kcp-sys (#2277) 2026-05-24 23:11:16 +08:00
fanyangandGitHub 73bea01f40 fix: support env vars for easytier-web (#2280)
* fix: support env vars for easytier-web
* fix: hide sensitive web env values
2026-05-22 23:59:16 +08:00
韩嘉乐andGitHub 0378191783 feat: add the management of config_store_snapshot (#2271) 2026-05-22 01:54:43 +08:00
ParkGardenandGitHub d5fa6a608d fix: Magisk module incorrectly matches the lookup main rule in Android 15 (#2259)
Fix the issue where the Magisk module incorrectly matches the lookup main rule in Android 15's cellular network rules, causing data plane connectivity failure
2026-05-18 12:51:49 +08:00
Luna YaoandGitHub 811f151155 refactor: rpc build (#2244)
rewrite rpc build with quota crate
2026-05-15 14:01:56 +08:00
Luna YaoandGitHub 8428a89d2d refactor: introduce HedgeExt for task hedging; rewrite NatDstQuicConnector (#2229) 2026-05-12 20:26:16 +08:00
513695297c [OHOS] feat: Enhance Rust kernel with config management and routing improvements (#2227)
* [OHOS.with ai] 将配置管理/配置分享/路由聚合/实例状态解析下沉至 Rust 内核,收敛职责并提升性能 (#2209)

* feat: add ohrs config store and startup error logging

* feat: full ability core for ohos

* feat: full ability core for ohos

* feat: clean code

---------

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

* fix: 添加缺失文件

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

* fix: rustfmt

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

* fix: 修复Option适配错误

* fix: rustfmt

* fix: rustfmt

---------

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

- add opt-in Windows UDP broadcast relay config flag and CLI/env plumbing
- capture local UDP broadcasts with Windows raw sockets, normalize packets, and inject them via PeerManager
2026-05-09 09:56:31 +08:00
fanyangandGitHub 55f15bb6f0 fix(connector): classify manual reconnect timeouts by stage (#2062) 2026-05-08 22:08:51 +08:00
Luna YaoandGitHub 96fd39649a revert UPX version to 4.2.4 in core.yml (#2221) 2026-05-07 18:49:40 +08:00
KKRainbowandGitHub 74fc8b300d chore: bump version to 2.6.4 (#2219) 2026-05-07 13:48:51 +08:00
KKRainbowandGitHub baeee40b79 fix machine uid and easytier-web panic (#2215)
1. fix(web-client): persist and migrate machine id
2. fix panic when easytier-web session receive malformat packet
2026-05-07 00:57:42 +08:00
fanyangandGitHub 4342c8d7a2 fix: add missing CLI help text (#2213) 2026-05-05 17:05:34 +08:00
KKRainbowandGitHub 1178b312fa fix foreign network entry leak (#2211) 2026-05-05 11:01:44 +08:00
fanyangandGitHub 362aa7a9cd fix: allow omitted ACL config fields (#2206) 2026-05-04 00:47:24 +08:00
KKRainbowandGitHub 12a7b5a5c5 fix: scope peer center server data to instance (#2198)
Stop sharing PeerCenterServer state through a process-global map so local and foreign-network services cannot mix peer-center data when peer ids overlap.
2026-05-02 01:43:01 +08:00
fanyangandGitHub 4eba9b07b6 fix(web-client): keep retrying unreachable config server (#2140)
Defer config-server connector creation into the web client retry loop so
service startup does not fail when network or DNS is unavailable.
2026-05-02 00:09:48 +08:00
KKRainbowandGitHub 1b48029bdc fix: clean stale foreign network state (#2197)
- clear foreign-network traffic metric peer caches on peer removal and network cleanup
- release reserved foreign-network peer IDs on handshake/add-peer error paths
- avoid creating no-op foreign-network token buckets when limits are unlimited
- shrink relay/session maps after cleanup and remove unused peer-center global data entries
2026-05-01 23:30:51 +08:00
KKRainbowandGitHub 3542e944cb fix(quic): prune stopped endpoints from pool (#2195)
* remove wss port 0 compatibility code
* fix(quic): prune stopped endpoints from pool
2026-05-01 18:51:39 +08:00
KKRainbowandGitHub 852d1c9e14 feat(gui): add UPnP and public IPv6 advanced options (#2194)
Expose disable-upnp and ipv6_public_addr_auto in the shared web/GUI config editor
bump release metadata to 2.6.3.
2026-05-01 13:45:19 +08:00
KKRainbowandGitHub 4958394469 fix: protect self peer during credential refresh and allow need-p2p peers through public server (#2192)
* fix: protect self peer during credential refresh

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

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

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

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

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

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

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

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

Changes:

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

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

pr

* do not run build on draft PRs

pr

* enable fail-fast for build workflows
2026-04-08 08:43:03 +08:00
Luna YaoandGitHub 371b4b70a3 proto(utils): add TransientDigest trait (#2071) 2026-04-08 00:06:48 +08:00
Luna YaoandGitHub b2cc38ee63 chore(clippy): disallow some methods from itertools (#2075) 2026-04-07 16:27:33 +08:00
Luna YaoandGitHub 79b562cdc9 drop peer_mgr in time (#2064) 2026-04-06 11:31:05 +08:00
fanyangandGitHub e3f089251c fix(ospf): mitigate route sync storm under connection flapping (#2063)
Addresses issue #2016 where nodes behind unstable networks
(e.g. campus firewalls) cause excessive traffic that can freeze
the remote node.

Two changes in peer_ospf_route.rs:

- Make do_sync_route_info only trigger reverse sync_now when
  incoming data actually changed the route table or foreign
  network state.  The previous unconditional sync_now created
  an A->B->A->B ping-pong cycle on every RPC exchange.

- Add exponential backoff (50ms..5s) to session_task retry loop.
  The previous fixed 50ms retry produced ~20 RPCs/s during
  sustained network instability.
2026-04-06 11:26:20 +08:00
fanyangandGitHub cf6dcbc054 Fix IPv6 TCP tunnel display formatting (#1980)
Normalize composite tunnel display values before rendering peer and
debug output so IPv6 tunnel types no longer append `6` to the port.

- Preserve prefixes like `txt-` while converting tunnel schemes to
  their IPv6 form.
- Recover malformed values such as `txt-tcp://...:110106` into
  `txt-tcp6://...:11010`.
- Reuse the normalized remote address display in CLI debug output.
2026-04-05 22:12:55 +08:00
fanyangandGitHub 2cf2b0fcac feat(cli): implement connector add/remove, drop peer stubs (#2058)
Implement the previously stubbed connector add/remove CLI commands
using PatchConfig RPC with InstanceConfigPatch.connectors, and
remove the peer add/remove stubs that had incorrect semantics.
2026-04-05 13:56:17 +08:00
aa0cca3bb6 build(deps): bump quinn-proto in /easytier-contrib/easytier-ohrs (#2059)
Bumps [quinn-proto](https://github.com/quinn-rs/quinn) from 0.11.13 to 0.11.14.
- [Release notes](https://github.com/quinn-rs/quinn/releases)
- [Commits](https://github.com/quinn-rs/quinn/compare/quinn-proto-0.11.13...quinn-proto-0.11.14)

---
updated-dependencies:
- dependency-name: quinn-proto
  dependency-version: 0.11.14
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-05 13:16:33 +08:00
KKRainbowandGitHub fb59f01058 fix: reconcile webhook-managed configs and make disable_p2p more intelligent (#2057)
* reconcile infra configs on webhook validate
* make disable_p2p more intelligent
* fix stats
2026-04-04 23:41:57 +08:00
Luna YaoandGitHub e91a0da70a refactor: listener/connector protocol abstraction (#2026)
* fix listener protocol detection
* replace IpProtocol with IpNextHeaderProtocol
* use an enum to gather all listener schemes
* rename ListenerScheme to TunnelScheme; replace IpNextHeaderProtocols with socket2::Protocol
* move TunnelScheme to tunnel
* add IpScheme, simplify connector creation
* format; fix some typos; remove check_scheme_...;
* remove PROTO_PORT_OFFSET
* rename WSTunnel.. -> WsTunnel.., DNSTunnel.. -> DnsTunnel..
2026-04-04 10:55:58 +08:00
Luna YaoandGitHub 9cc617ae4c ci: build rpm package (#2044)
* add rpm to ci
* rename build_filter to build-filter
* use prepare-pnpm action
2026-04-04 10:32:08 +08:00
韩嘉乐andGitHub e4b0f1f1bb Rename libeasytier_ohrs.so to libeasytier_release.so when build release package (#2056)
Rename shared library file for release.
2026-04-04 10:29:37 +08:00
Luna YaoandGitHub 443c3ca0b3 fix: append address of reverse proxy to remote_addr (#2034)
* append address of reverse proxy to remote_addr
* validate proxy address in test
2026-03-30 16:48:23 +08:00
Luna YaoandGitHub 55a0e5952c chore: use cfg_aliases for mobile (#2033) 2026-03-30 16:38:39 +08:00
KKRainbowandGitHub 1dff388717 bump version to v2.6.0 (#2039) 2026-03-30 15:50:07 +08:00
Luna YaoandGitHub 61c741f887 add BoxExt trait (#2036) 2026-03-30 13:25:53 +08:00
ParkGardenandGitHub 01dd9a05c3 fix: 重构了 Magisk 模块的 easytier_core.sh, action.sh, uninstall.sh 三个脚本的逻辑,优化参数解析与进程管理,调整措辞 (#1964) 2026-03-30 13:18:42 +08:00
KKRainbowandGitHub 8c19a2293c fix(windows): avoid pnet interface enumeration panic (#2031) 2026-03-29 23:16:44 +08:00
KKRainbowandGitHub a1bec48dc9 fix android vpn permission grant (#2023)
* fix android vpn permission grant
* fix url input behaviour
2026-03-29 23:16:32 +08:00
KKRainbowandGitHub 7e289865b2 fix(faketcp): avoid pnet interface lookup on windows (#2029) 2026-03-29 19:26:29 +08:00
fanyangandGitHub 742c7edd57 fix: use default connection loss rate for peer stats (#2030) 2026-03-29 19:25:25 +08:00
Luna YaoandGitHub b71a2889ef suppress clippy warnings when no feature flags are enabled (#2028) 2026-03-29 11:02:23 +08:00
KKRainbowandGitHub bcd75d6ce3 Add instance recv limiter in peer conn (#2027) 2026-03-29 10:28:02 +08:00
Luna YaoandGitHub d4c1b0e867 fix: read X-Forwarded-For from HTTP header of WS/WSS (#2019) 2026-03-28 22:20:46 +08:00
KKRainbowandGitHub b037ea9c3f Relax private mode foreign network secret checks (#2022) 2026-03-28 22:19:23 +08:00
Luna YaoandGitHub b5f475cd4c filter overlapped proxy cidr (#2024) 2026-03-28 09:40:05 +08:00
Luna YaoandGitHub eaa4d2c7b8 test: use taiki-e/install-action for cargo-hack (#2020) 2026-03-28 00:07:59 +08:00
Luna YaoandGitHub e160d9b048 ci: remove aes-gcm from check (#1925) 2026-03-27 22:48:22 +08:00
KKRainbowandGitHub 0aeea39fbe refactor(gui): collapse public server and standalone into initial peer list (#2017)
The GUI exposed three networking modes: public server, manual, and standalone. In practice EasyTier does not have a server/client role distinction here. Those options only mapped to different peer bootstrap shapes, which made the product model misleading and pushed users toward a non-existent "public server" concept.

This change rewrites the shared configuration UX around initial nodes. Users now add or remove one or more initial node URLs directly, and the UI explains that EasyTier networking works like plugging in a cable: once a node connects to one or more existing nodes, it can join the mesh. Initial nodes may be self-hosted or shared by others.

To preserve compatibility, the frontend keeps the legacy fields and adds normalization helpers in the shared NetworkConfig layer. Old configs are read as initial_node_urls, while saves, runs, validation, config generation, and persisted GUI config sync still denormalize back into the current backend shape: zero initial nodes -> Standalone, one -> PublicServer, many -> Manual. This avoids any proto or backend API change while making old saved configs and imported TOML files load cleanly in the new UI.

Code changes:

- add initial_node_urls plus normalize/denormalize helpers in the shared frontend NetworkConfig model

- remove the mode switch and public-server/manual specific inputs from the shared Config component and replace them with a single initial-node list plus explanatory copy

- update Chinese and English locale strings for the new terminology

- normalize configs received from GUI/web backends and denormalize them before outbound API calls

- normalize GUI save-config events before storing them in localStorage so legacy payloads remain editable under the new model
2026-03-27 11:37:09 +08:00
KKRainbowandGitHub e000636d83 feat(stats): add by-instance traffic metrics (#2011) 2026-03-26 13:46:33 +08:00
Luna YaoandGitHub 8e4dc508bb test: improve test_txt_public_stun_server with timeout and retry mechanism (#2014) 2026-03-26 09:32:07 +08:00
Luna YaoandGitHub e2684a93de refactor: use strum on EncryptionAlgorithm, use Xor as default when AesGcm not available (#1923) 2026-03-25 18:42:34 +08:00
KKRainbowandGitHub 1d89ddbb16 Add lazy P2P demand tracking and need_p2p override (#2003)
- add lazy_p2p so nodes only start background P2P for peers that actually have recent business traffic
- add need_p2p so specific peers can still request eager background P2P even when other nodes enable lazy mode
- cover the new behavior with focused connector/peer-manager tests plus three-node integration tests that verify relay-to-direct route transition
2026-03-23 09:38:57 +08:00
KKRainbowandGitHub 2bfdd44759 multi_fix: harden peer/session handling, tighten foreign-network trust, and improve web client metadata (#1999)
* machine-id should be scoped unbder same user-id
* feat: report device os metadata to console
* fix sync root key cause packet loss
* fix tun packet not invalid
* fix faketcp cause lat jitter
* fix some packet not decrypt
* fix peer info patch, improve performance of update self info
* fix foreign credential identity mismatch handling
2026-03-21 21:06:07 +08:00
Luna YaoandGitHub 77966916c4 cargo: add used features for windows-sys (#1924) 2026-03-17 14:10:50 +08:00
TsXorandGitHub 26b7455c1e ignores eol difference for auto-generated files (#1997) 2026-03-16 23:40:38 +08:00
KKRainbowandGitHub 8922e7b991 fix: foreign credential handling and trusted key visibility (#1993)
* fix foreign credential handling
* allow list foreign network trusted keys
* fix(gui): delete removed config-server networks
* fix(web): reset managed instances on first sync
2026-03-16 22:19:31 +08:00
KKRainbowandGitHub e6ac31fb20 feat(web): add webhook-managed machine access and multi-instance CLI support (#1989)
* feat: add webhook-managed access and multi-instance CLI support
* fix(foreign): verify credential of foreign credential peer
2026-03-15 12:08:50 +08:00
KKRainbowandGitHub c8f3c5d6aa feat(credential): support custom credential ID generation (#1984)
introduces support for custom credential ID generation, allowing users to specify their own credential IDs instead of relying solely on auto-generated UUIDs.
2026-03-12 00:48:24 +08:00
KKRainbowandGitHub 330659e449 feat(web): full-power RPC access + typed JSON proxy endpoint (#1983)
- extend web controller bindings to cover full RPC service set
- update rpc_service API wiring and session/controller integration
- generate trait-level json_call_method in rpc codegen
- route restful proxy-rpc requests via scoped typed clients
- add json-call regression tests and required Sync bound fixes~
2026-03-11 20:32:37 +08:00
MaxwellandGitHub 80043df292 script: introduce EasyTier powershell installer (#1975) 2026-03-11 11:57:03 +08:00
KKRainbowandGitHub ecd1ea6f8c feat(web): implement secure core-web tunnel with Noise protocol (#1976)
Implement end-to-end encryption for core-web connections using the
Noise protocol framework with the following changes:

Client-side (easytier/src/web_client/):
- Add security.rs module with Noise handshake implementation
- Add upgrade_client_tunnel() for client-side handshake
- Add Noise frame encryption/decryption via TunnelFilter
- Integrate GetFeature RPC for capability negotiation
- Support secure_mode option to enforce encrypted connections
- Handle graceful fallback for backward compatibility

Server-side (easytier-web/):
- Accept Noise handshake in client_manager
- Expose encryption support via GetFeature RPC

The implementation uses Noise_NN_25519_ChaChaPoly_SHA256 pattern for
encryption without authentication. Provides backward compatibility
with automatic fallback to plaintext connections.
2026-03-10 08:48:08 +08:00
KKRainbowandGitHub 694b8d349d feat(credential): enforce signed credential distribution across mixed admin/shared topology (#1972) 2026-03-10 08:37:33 +08:00
KKRainbowandGitHub ef44027f57 feat(credential): improve credential peer routing and visibility (#1971)
- improve credential peer filtering and related route lookup behavior
- expose credential peer information through CLI and API definitions
- add and refine tests for credential routing and peer interactions
2026-03-08 14:06:33 +08:00
KKRainbowandGitHub f3db348b01 fix: resolve slow exit and reduce test timeouts (#1970)
- Explicitly shutdown tokio runtime on launcher cleanup to fix slow exit
- Add timeout to tunnel connector in tests to prevent hanging
- Reduce test wait durations from 5s to 100ms for faster test execution
- Bump num-bigint-dig from 0.8.4 to 0.8.6
2026-03-08 12:27:42 +08:00
KKRainbowandGitHub c4eacf4591 feat(credential): implement credential peer auth and trust propagation (#1968)
- add credential manager and RPC/CLI for generate/list/revoke
- support credential-based Noise authentication and revocation handling
- propagate trusted credential metadata through OSPF route sync
- classify direct peers by auth level in session maintenance
- normalize sender credential flag for legacy non-secure compatibility
- add unit/integration tests for credential join, relay and revocation
2026-03-07 22:58:15 +08:00
59d4475743 feat: relay peer end-to-end encryption via Noise IK handshake (#1960)
Enable encryption for non-direct nodes requiring relay forwarding.
When secure_mode is enabled, peers perform Noise IK handshake to
establish an encrypted PeerSession. Relay packets are encrypted at
the sender and decrypted at the receiver. Intermediate forwarding
nodes cannot read plaintext data.

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: KKRainbow <5665404+KKRainbow@users.noreply.github.com>
2026-03-07 14:47:22 +08:00
22b4c4be2c fix: guard macos-ne feature with target_os = "macos" in cfg expressions (#1962)
All 13 occurrences of `any(target_os = "ios", feature = "macos-ne")` are
replaced with `any(target_os = "ios", all(target_os = "macos", feature = "macos-ne"))`.

Previously, enabling `macos-ne` on non-macOS platforms (e.g. `--all-features`
on Linux) would incorrectly compile macOS/mobile-specific code paths, causing
build failures or wrong runtime behavior.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-05 00:06:21 +08:00
Luna YaoandGitHub 5f31583a84 refactor: 使用 tracing 输出日志 (#1856)
* change all println to tracing
2026-03-04 09:52:23 +08:00
Mg PigandGitHub 1d25240d8c refactor(ui): extract URL input components and enhance UI responsiveness (#1819) 2026-03-04 09:49:15 +08:00
eeb507d6ea fix: register PeerCenterRpc in management API server so CLI peer-center works (#1929)
PeerCenterRpc was only registered in the per-instance peer-to-peer RPC
manager (domain = network_name), but not in the management API server
(domain = ""). The CLI connects to the management API with an empty
domain, causing "Invalid service name: PeerCenterRpc" errors.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-04 09:37:37 +08:00
9e9916efa5 fix(connector): skip self-connection when peer shares local interface IPs (#1941)
When two EasyTier instances run on the same machine and share the same
network, the direct connector would expand a remote peer's 0.0.0.0
listener into local interface IPs and then attempt to connect to
itself, causing an infinite loop of failed connection attempts.

The existing `peer_id != my_peer_id` guard does not cover this case
because the two instances have different peer IDs despite sharing the
same physical network interfaces.

Fix by adding a self-connection check in `spawn_direct_connect_task`:
before spawning a connect task, compare the candidate (scheme, IP,
port) against the local running listeners. If a local listener matches
on all three dimensions — accounting for 0.0.0.0/:: wildcards by
checking membership in the local interface IP sets — the candidate is
silently dropped with a DEBUG log message.

The fix covers all four code paths:
- IPv4 unspecified (0.0.0.0) expansion loop
- IPv4 specific-address branch
- IPv6 unspecified (::) expansion loop
- IPv6 specific-address branch

The TESTING flag logic is untouched so existing unit tests are
unaffected.

* refactor(connector): replace is_self_connect closure with GlobalCtx::should_deny_proxy (#1954)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
2026-03-04 09:36:35 +08:00
helloandGitHub db6b9e3684 feat: core config server use last path segment as user name (#1931) 2026-03-03 18:24:28 +08:00
Mg PigandGitHub ff24332e23 feat(web): add OIDC SSO login support (#1943) 2026-03-03 18:23:31 +08:00
fanyangandGitHub d4ff0b1767 build(deps): upgrade vite to 5.4.21 in frontend and gui packages (#1950) 2026-03-01 13:47:02 +08:00
Mg PigandGitHub 5716f7f16b fix(web): allow configuring listen address for API and web servers (#1919) (#1948) 2026-03-01 01:02:31 +08:00
fanyangandGitHub e5bd8f9e24 build(deps): upgrade minimatch to 10.2.4 (#1949) 2026-02-28 22:40:47 +08:00
sky96111andGitHub b56bcfb4b0 fix: increase websocket peer connection timeout to 20 seconds (#1939)
- Add ws/wss protocols to long timeout list
2026-02-28 18:26:19 +08:00
fb95b4827c build(deps): bump axios from 1.11.0 to 1.13.6 in frontend packages (#1947)
Addresses security vulnerabilities in axios <1.13.5. Updates the
declared specifier to ^1.13.5 in all three frontend package.json
files and regenerates both npm and pnpm lock files (resolved: 1.13.6).

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 11:17:18 +08:00
fanyangandGitHub a8f7226195 fix(foreign_network): set avoid_relay_data when relay_data is false (#1935) 2026-02-25 09:30:24 +08:00
dependabot[bot]andGitHub e6ee485352 build(deps-dev): bump vite from 5.4.10 to 5.4.21 in /easytier-web/frontend-lib (#1922)
* build(deps-dev): bump vite in /easytier-web/frontend-lib

Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.10 to 5.4.21.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.21/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.21/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 5.4.21
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-23 22:47:29 +08:00
helloandGitHub 73291a3a1c feat: Update Cargo.toml to add support for tls1.2 when use wss (#1917) 2026-02-20 18:01:21 +08:00
fanyangandGitHub f737708f45 fix: avoid panic on malformed short tunnel packets (#1904) 2026-02-18 00:04:30 +08:00
fanyangandGitHub aa24d09aa2 fix: replace stale magic DNS records on IP change (#1906)
Magic DNS updates are full snapshots, so appending routes keeps old IPs and returns duplicate A records. Replace each client's previous routes on update and add a regression test to ensure hostname resolution keeps only the latest IP.
2026-02-16 13:20:11 +08:00
fe4e77979d fix: avoid panic for quic peer urls using port 0 (#1905)
Prevent crashes when users input quic://...:0 by rejecting port 0 explicitly and propagating connect setup errors. Add a regression test to ensure invalid QUIC targets fail gracefully.

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-14 17:10:29 +08:00
Chenx DustandGitHub 7a26640c26 feat: support macOS Network Extension (#1902)
* feat: support macOS Network Extension
* fix: disable macOS NE feature in cargo hack check
2026-02-14 14:54:36 +08:00
Mg PigandGitHub 5a777959e3 ui: clarify encryption checkbox description in locales (#1841) 2026-02-13 16:04:26 +08:00
Mg PigandGitHub 3512a80597 feat(web): add --disable-registration flag to disable user registration (#1881) 2026-02-13 16:03:11 +08:00
ZkiteflyandGitHub 011770a601 Update http_connector.rs (#1900) 2026-02-13 16:02:32 +08:00
Chenx DustandGitHub 6475724d2e fix: toggle_window_visibility with focus check (#1888)
* refactor: better logics for toggle_window_visibility
2026-02-11 16:50:36 +08:00
Mg Pigandsijie.sun 85e9029577 feat: add Nix CI workflow and update flake.lock dependencies (#1872) 2026-02-10 18:11:35 +08:00
Luna YaoandGitHub b6e292cce3 ci: use shared key for build workflow (#1868) 2026-02-04 09:48:55 +08:00
KKRainbowandGitHub c58140fb47 update rust to 1.93 (#1865) 2026-02-04 09:48:43 +08:00
Luna YaoandGitHub aebb7facfa drop permit reserved by poll_reserve (#1858) 2026-02-03 11:14:11 +08:00
Chenx DustandGitHub 1e2124cb99 fix: force set tun fd when received (#1860) 2026-02-03 11:13:31 +08:00
Chenx DustandGitHub e1cbd07d1f feat: separate zstd and faketcp into features (#1861)
* feat: separate faketcp into a feature
* fix: no need to initialize out_len
* feat: separate zstd into a feature
* clippy: remove unnecessary cast, because for unix size_t always equals usize
2026-02-03 11:12:33 +08:00
韩嘉乐andGitHub 7750e81168 CI(ohos): add a condition to check for the publish code (#1863)
Added a condition to check for the presence of a release code when running the publish step
2026-02-03 11:11:45 +08:00
KKRainbowandGitHub bf3edbd28f remove src modified flag from pm hdr (#1857) 2026-02-02 16:47:26 +08:00
Luna YaoandGitHub cd2cf56358 refactor: handle quic proxy internally instead of use external udp port (#1743)
* deprecate quic_listen_port, add disable_relay_quic and enable_relay_foreign_network_quic
* add set_src_modified to TcpProxyForWrappedSrcTrait
* prioritize quic over kcp
2026-02-02 11:53:40 +08:00
KKRainbowandGitHub 21f4a944a7 fix perf degraded because of impact of is_empty() of dashmap (#1854) 2026-02-01 08:51:18 +08:00
KKRainbowandGitHub 9617005136 make udp->ring transmit reliable (#1851) 2026-01-31 17:23:45 +08:00
deddeyandGitHub c85d1d41b3 allow set TUN dev name on FreeBSD (#1823)
Also rename stale interfaces from previous runs before creating new ones.
Works around rust-tun reusing existing tun0 instead of configured name.

Tested on FreeBSD 14.1
2026-01-30 23:51:52 +08:00
KKRainbowandGitHub 9e3c9228bb improve perf of remove_network in foreign net mgr (#1847) 2026-01-30 23:04:31 +08:00
Luna YaoandGitHub acd7c85ff6 ci: speed up test with matrix (#1830)
* add an action to install pnpm packages
* add an action to prepare build environment
* rewrite test workflow, using composite actions and matrix
2026-01-30 22:21:27 +08:00
KKRainbowandGitHub 8727221513 call remove_peer instead of remove_network when peer id not match (#1844) 2026-01-30 16:01:52 +08:00
cdedaf3f63 refactor(quic): remove quinn encryption (#1831)
* use quinn-plaintext
* remove server_cert in QUICTunnelListener
* remove some customized transport config
* leave max_concurrent_bidi_streams as default

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-30 10:21:59 +08:00
KKRainbowandGitHub ffe5644ddc add token bucket limiter on peer conn recv (#1842)
We should limit peer conn recv to make sure we don't recv too much from peers.
2026-01-29 16:12:26 +08:00
Chenx DustandGitHub ccc684a9ab Fix: Fixed compilation issue after partially removing the feature flag (#1835) 2026-01-28 21:38:34 +08:00
977e502150 feat(cli): add column truncation controls (#1838)
- drop low-priority columns when tables exceed terminal width
- truncate optional columns to fit remaining width
- add --no-trunc flag to disable truncation
- compute column widths using unicode display width

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-28 14:50:14 +08:00
Mg PigandGitHub 518d26b25f feat: add X-Network-Name header to HTTP connector requests (#1839)
This allows HTTP redirect servers to provide network-specific node
lists based on the client's network identity. Updated unit tests
to verify the header is correctly sent.
2026-01-28 14:48:45 +08:00
KKRainbowandGitHub 101f416268 Introduce secure mode (part 1) (#1808)
Use noise protocol on handshake. Check peer's public key if needed. Also support rekey and replay attack prevention.

E2EE and temporary password will be implemented based on this.
2026-01-25 20:16:51 +08:00
Chenx DustandGitHub ffa08d1c43 feat: add peer_id in MyNodeInfo (#1821) 2026-01-22 22:44:37 +08:00
韩嘉乐andGitHub cf3f9169b7 CI(ohos): Enhance CI workflow for release package builds (#1812)
Added support for building and publishing release packages based on tags.
2026-01-20 12:25:10 +08:00
KKRainbowandGitHub 8343cd5e76 fix config loss when run network (#1802) 2026-01-17 00:58:42 +08:00
KKRainbowandGitHub 005b321f62 allow open rpc port in gui normal mode (#1795)
* allow open rpc port for gui normal mode
* downgrade dev tool console
2026-01-16 11:12:32 +08:00
KKRainbowandGitHub 53264f67bf fix peer establish direct conn with subnet proxy to one of local interface (#1782)
* fix peer establish direct conn with subnet proxy to one of local interface

* fix peer mgr ref loop
2026-01-15 01:00:32 +08:00
韩嘉乐andGitHub f8b34e3c86 Merge pull request #1787 from EasyTier/FrankHan052176-patch-1
action[ohos] fix the cnt of commit in ohos.yml
2026-01-13 23:58:26 +08:00
韩嘉乐andGitHub ce1bdac2bc action[ohos] fix the cnt of commit in ohos.yml 2026-01-13 22:57:43 +08:00
bd8f01fb26 Add Nushell completion script generation support (#1756)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-01-11 18:41:02 +08:00
b590700540 feat: support unix socket tunnel (for ios) (#1779)
Co-authored-by: Page Chen <pagechen04@gmail.com>
2026-01-11 16:37:32 +08:00
Chenx DustandGitHub 48c5c23f9b feat: support compile for iOS (#1777) 2026-01-11 16:36:58 +08:00
朝倉水希andGitHub f4f591d14c fix: outbound packet not dropped by acl (#1766) 2026-01-08 19:58:23 +08:00
Mg PigandGitHub 0c16e2211b feat(gui): persist and restore last used network instance ID (#1762) 2026-01-08 17:03:51 +08:00
4bfea06a12 docs: update locales (#1755)
Co-authored-by: KKRainbow <443152178@qq.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-08 11:08:32 +08:00
桜井 ホタルandGitHub 057ee9f2c5 Resolves the issue of DNS resolution failure after installing KSU modules, resulting in inability to connect to nodes. (#1761) 2026-01-08 11:07:52 +08:00
7f48ca54a3 Implement requesting tun_fd with tokio channel. (#1734)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-04 21:04:43 +08:00
helloandGitHub ee5227130c feat: Update Cargo.toml for easytier-gui and android app to support tls1.2 (#1744) 2026-01-04 21:03:34 +08:00
韩嘉乐andGitHub 2e0d9a2b54 Refactor EasyTier version resolution in workflow (#1747)
Updated the workflow to resolve the EasyTier version based on the latest commit and tag information.
2026-01-04 21:02:55 +08:00
编程小白andGitHub c5d732773f Convert dead URL to ASCII before socket address lookup (#1739) 2026-01-02 18:49:23 +08:00
狂男风andGitHub 88a45d1156 use 80/443 as ws/wss default port (#1700) 2026-01-01 01:31:38 +08:00
KKRainbowandGitHub 4e651a72f7 allow loopback src address in listener (#1730) 2026-01-01 00:41:56 +08:00
Mg PigandGitHub 7c563153ae fix: ensure proxy routes update correctly on NIC (#1729) 2025-12-31 22:36:45 +08:00
KKRainbowandGitHub cb81c0df85 respond packet should not be dropped if request packet is already allowed (#1725) 2025-12-31 08:14:39 +08:00
9c316ea01c fix socks5 and tcp forward mem leak (#1721)
Co-authored-by: sijie.sun <sijie.sun@smartx.com>
2025-12-31 00:01:44 +08:00
XuDaojieandGitHub 541fc664e3 update[gui]:将macOS端的应用图标修改为Big Sur风格圆角图标 (#1723) 2025-12-30 22:22:39 +08:00
Mg PigandGitHub 18478b7c4b fix(android): update vpn routes when proxy cidrs change (#1717) 2025-12-30 19:26:42 +08:00
韩嘉乐andGitHub 650323faef [Ohos] 仅在push时执行发布操作,避免流水线运行错误 (#1718) 2025-12-29 13:57:02 +08:00
狂男风andGitHub ed131272d4 fix(gui): open_log_dir not working (#1714)
* fix(mobile): open_log_dir not working on android
2025-12-28 23:20:29 +08:00
KKRainbowandGitHub 39b056c87a bump version to v2.5.0 (#1715) 2025-12-28 23:19:30 +08:00
KKRainbowandGitHub c19cd1bff3 add tcp hole punching (#1713)
add tcp hole punching and tcp stun test
2025-12-28 21:35:30 +08:00
狂男风andGitHub 37531507db fix(mobile): logs unreachable on android (#1710) 2025-12-27 20:04:18 +08:00
KKRainbowandGitHub ca9b4c58b1 fix windivert cause stack overflow (#1711) 2025-12-27 19:31:42 +08:00
KKRainbowandGitHub 4341bcba5d improve faketcp, handle tcp GSO correctly (#1708)
Current implementation falsely drop GSO-merged tcp packet, and cause unexpected packet loss.
2025-12-26 23:46:17 +08:00
韩嘉乐andGitHub 0be4ac1fa5 [Ohos] 使用Commit计数器替代Commit Hash作为版本尾缀 (#1703) 2025-12-25 20:42:43 +08:00
KKRainbowandGitHub 28cd6da502 Add fake tcp tunnel (experimental) (#1673)
support faketcp to avoid tcp-over-tcp problem.
linux/macos/windows are supported.

better to be used in internet env, the maximum 
performance is majorly limited by windivert/raw socket.
2025-12-25 00:10:32 +08:00
狂男风andGitHub 0712ef762d Fix logic error in relay network whitelist resolving (#1692) 2025-12-23 08:25:45 +08:00
韩嘉乐andGitHub eee7d7a1ed 增加Ohos流水线发布步骤执行条件 (#1695) 2025-12-22 21:40:28 +08:00
Burning_TNTandGitHub 4c58def0db Make release.yml available in forks (#1689) 2025-12-21 21:13:53 +08:00
MomoandGitHub c6a32e4467 fix: magic dns tld_dns_zone were not working properly (#1686)
* fix: magic dns tld_dns_zone failed to get updated
2025-12-21 21:13:39 +08:00
韩嘉乐andGitHub 30f0ff16ca Merge pull request #1678 from EasyTier/ohpm
[鸿蒙] 在流水线中增加上传中心仓与上传华为云私仓流程,增加华为云流水线Webhook
2025-12-16 12:06:19 +08:00
FrankHan 38d117ee44 [鸿蒙] 在流水线中增加上传中心仓与上传华为云私仓流程,增加华为云流水线Webhook 2025-12-16 11:50:05 +08:00
KKRainbowandGitHub 7aba65ea32 enhance port forward (#1662) 2025-12-09 22:16:16 +08:00
TungliesandGitHub fe4dff5df0 perf: simplify method signatures and reduce clone across multiple files (#1663) 2025-12-09 16:47:57 +08:00
KKRainbowandGitHub 2bc51daa98 fix whitelist cause packets of other protocal dropped (#1660) 2025-12-08 21:56:27 +08:00
838b6101b9 Make ospf route more effiencient (#1512)
Avoid iterate all peer info and conn list when building sync request.

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-08 13:14:47 +08:00
韩嘉乐andGitHub 056c9da781 [EasyTier-ohrs] Use NetworkConfig instead of TomlConfig, and add CompressionAlgorithm and EncryptionAlgorithm to NetworkConfig. (#1654) 2025-12-06 23:23:22 +08:00
2a656d6a0c fix(core): Fix sleep-wake reconnect by resetting alive_conn_urls (#1593)
Co-authored-by: sijie.sun <sijie.sun@smartx.com>
2025-12-05 14:31:08 +08:00
KKRainbowandGitHub 43a650f9ab set FORCE_USE_CONN_LIST default to false (#1652)
this is falsely set to true and will casue compatibility issue
2025-12-05 00:26:04 +08:00
C.C.andGitHub 88a55859ac fix(web): remove trailing slash from api base url (#1621) 2025-12-04 23:06:28 +08:00
dawn-lcandGitHub d686c8721f feat(install): enhance installation script functionality (#1641)
* feat(install): enhance installation script functionality
* fix temp file extname
2025-12-04 23:06:06 +08:00
Mg PigandGitHub 0a718163fd feat(gui): GUI add support to connect to config server (#1596) 2025-12-04 23:05:36 +08:00
Mg PigandGitHub 53f279f5ff feat(core): Support environment variable parsing in config files (#1640) 2025-12-02 17:54:31 +08:00
Mg PigandGitHub ae6d929f4a fix(mobile): Add DHCP polling to fix Android VPN startup failure (#1628) 2025-12-01 01:13:05 +08:00
starrainandGitHub bb82b3a5b0 fix(elevate): fix panic on NixOS (#1634) 2025-12-01 01:12:08 +08:00
Mg PigandGitHub 70b122fb91 feat(gui): macOS UX Improvements (#1631) 2025-12-01 01:11:36 +08:00
狂男风andGitHub 67cba2c326 feat(mobile): Enhance the Magic DNS support via VpnService on Android (#1617)
* Add DNS route if accept_dns is enabled
* Update doStartVpn to accept optional DNS parameter
2025-11-27 16:53:40 +08:00
sky96111andGitHub b86692d009 fix(android): use network-assigned DNS when no DNS is provided (#1612) 2025-11-26 18:24:05 +08:00
狂男风andGitHub 28e645a277 Add IPv6 address to VPN service (#1615) 2025-11-26 17:15:19 +08:00
Mg PigandGitHub 1f2517c731 feat(gui): add service and remote mode support (#1578)
This PR fundamentally restructures the EasyTier GUI, introducing support for service mode and remote mode, transforming it from a simple desktop application into a powerful network management terminal. This change allows users to persistently run the EasyTier core as a background service or remotely manage multiple EasyTier instances, greatly improving deployment flexibility and manageability.
2025-11-25 13:59:27 +08:00
Sijie.SunandGitHub b44053f496 support p2p-only mode (#1598) 2025-11-20 08:20:27 +08:00
Sijie.SunandGitHub 5b9ac65477 update readme (#1594) 2025-11-17 16:00:32 +08:00
Mg PigandGitHub d726d46a00 fix: Preserve disable_sym_hole_punching setting on edit (#1589) 2025-11-15 18:57:59 +08:00
Mg PigandGitHub 1273426009 feat: Enable core to use local config files while being managed via the web (#1540) 2025-11-08 20:32:00 +08:00
Sijie.SunandGitHub b50744690e easytier-web and uptime use mimalloc as allocator (#1559) 2025-11-08 11:07:33 +08:00
55b93454dc fix: clippy errors with stable toolchain and default features (#1553)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-07 20:08:39 +08:00
Mg PigandGitHub 89cc75f674 refactor: replace ConfigSource with bool parameter (#1516) 2025-11-04 13:48:10 +08:00
Mg PigandGitHub 6bb2fd9a15 feat(core): Refactor IDN and URL handling logic (#1533)
* feat(core): Refactor IDN and URL handling logic

* feat(tests): add dual_convert option for URL serialization in IDN tests
2025-11-03 22:15:40 +08:00
Mg PigandGitHub 8ab98bba8f feat(ui): make port forward config responsive (#1530) 2025-10-31 23:23:36 +08:00
韩嘉乐andGitHub 26d002bc2b The flowback solution of HarmonyOS 5 failed due to the anti-loop mechanism. (#1514) 2025-10-25 00:17:24 +08:00
Sijie.SunandGitHub 71679e889a allow sync conn with conn list when conn bitmap is too large (#1508) 2025-10-23 08:11:36 +08:00
Sijie.SunandGitHub 7485f5f64e make sure event is triggered when peer conn remove (#1507) 2025-10-22 23:37:19 +08:00
Mg PigandGitHub bbe8f9f810 feat(ui): Display network names and optimize list loading (#1503) 2025-10-22 13:40:36 +08:00
Mg PigandGitHub eba9504fc2 refactor(gui): refactor gui to use RemoteClient trait and RemoteManagement component (#1489)
* refactor(gui): refactor gui to use RemoteClient trait and RemoteManagement component
* feat(gui): Add network config saving and refactor RemoteManagement
2025-10-20 22:07:01 +08:00
kuaifanandGitHub 67ac9b00ff feat(gui): Optimize the data table column header style to prevent line breaks (#1497) 2025-10-19 16:50:17 +08:00
Sijie.SunandGitHub 3ffa6214ca fix subnet proxy deadloop (#1492)
* use LPM to determine subnet proxy dst.
* never allow subnet proxy traffic sending to self.
2025-10-19 15:46:51 +08:00
Mg PigandGitHub 6f278ab167 chore: update flake configuration (#1490) 2025-10-19 00:25:40 +08:00
Sijie.SunandGitHub f10b45a67c [easytier-uptime] support tag in node list (#1487) 2025-10-18 23:19:53 +08:00
Sijie.SunandGitHub cc8f35787e release dashmap memory (#1485) 2025-10-18 12:48:04 +08:00
Sijie.SunandGitHub 8f1786fa23 replace tachyonix with tokio mpsc in MpscTunnel (#1483)
tachyonix cannot correctly wakeup senders when the receiver is closed
and causing tasks deadlock and memory leak.
2025-10-17 00:09:13 +08:00
编程小白andGitHub 70dddeace3 Fix support for Chinese domain names (#1462) 2025-10-15 21:00:05 +08:00
Mg PigandGitHub 8cc9da9d6d fix(web): fix generate and parse config methods broken in #1465 (#1476) 2025-10-14 15:13:20 +08:00
Luna YaoandGitHub 5292b87275 Add quic-listen-port flag for customization of the port used by QUIC proxy (#1473) 2025-10-14 09:43:50 +08:00
Mg PigandGitHub 87b7b7ed7c refactor(web): Refactor web logic to extract reusable remote client management module (#1465) 2025-10-13 23:59:46 +08:00
imdingtalkandGitHub 999a486928 Improve update in installation script, decrease downtime(#1422) 2025-10-13 23:52:37 +08:00
TaurusXinandGitHub 627e989faa feat: show NAT type of all nodes in GUI (#1464) 2025-10-13 11:40:57 +08:00
Mg PigandGitHub af95312949 fix(acl): acl group cache add self group info (#1445) 2025-10-07 23:56:26 +08:00
Mg PigandGitHub a452c34390 fix(ohrs): update collect_network_infos to use synchronous method (#1444) 2025-10-04 23:12:38 +08:00
Mg PigandGitHub 4d5330fa0a refactor: get_running_info fn replace status polling with direct calls (#1441) 2025-10-04 21:43:34 +08:00
agusti mollandGitHub 5e48626cb9 add tld-dns-zone for customizing top-level domain (TLD) zone (#1436) 2025-10-04 00:18:10 +08:00
阿瓦andGitHub ad7dc3a129 use plist as macos service management config generator (#1439) 2025-10-04 00:14:45 +08:00
92fab5aafa feat(ohos) build har package (#1440)
Co-authored-by: niuhuan <20847533+niuhuan@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-02 22:29:49 +08:00
Mg PigandGitHub 841d525913 refactor(rpc): Centralize RPC service and unify API (#1427)
This change introduces a major refactoring of the RPC service layer to improve modularity, unify the API, and simplify the overall architecture.

Key changes:
- Replaced per-network-instance RPC services with a single global RPC server, reducing resource usage and simplifying management.
- All clients (CLI, Web UI, etc.) now interact with EasyTier core through a unified RPC entrypoint, enabling consistent authentication and control.
- RPC implementation logic has been moved to `easytier/src/rpc_service/` and organized by functionality (e.g., `instance_manage.rs`, `peer_manage.rs`, `config.rs`) for better maintainability.
- Standardized Protobuf API definitions under `easytier/src/proto/` with an `api_` prefix (e.g., `cli.proto` → `api_instance.proto`) to provide a consistent interface.
- CLI commands now require explicit `--instance-id` or `--instance-name` when multiple network instances are running; the parameter is optional when only one instance exists.

BREAKING CHANGE:  
RPC portal configuration (`rpc_portal` and `rpc_portal_whitelist`) has been removed from per-instance configs and the Web UI. The RPC listen address must now be specified globally via the `--rpc-portal` command-line flag or the `ET_RPC_PORTAL` environment variable, as there is only one RPC service for the entire application.
2025-10-02 20:30:39 +08:00
d2efbbef04 refactor: change magicdns to internal redirect (#1428)
To resolve issue #1419, DNS request packets are read directly and responses are sent back internally instead of being forwarded to the listening port.

The DNS service on fake_ip (100.100.100.101) no longer supports DNS-over-TCP.

Co-authored-by: Sijie.Sun <sunsijie@buaa.edu.cn>
2025-10-02 20:19:12 +08:00
Sijie.SunandGitHub 971ef82679 fix data not encrypted when no tun is enabled (#1435) 2025-10-01 11:16:24 +08:00
Mg PigandGitHub 020bf04ec4 refactor(config): unify runtime configuration management via ConfigRpc (#1397)
* refactor(config): unify runtime configuration management via ConfigRpc
* feat(tests): add config patch test and fix problem
2025-10-01 00:32:28 +08:00
韩嘉乐andGitHub 4d91582fd8 Update ohos-rs (#1434) 2025-09-30 23:51:58 +08:00
Sijie.SunandGitHub e9b4dbce6e use cargo ndk in jni build script (#1424) 2025-09-28 23:18:51 +08:00
R0SandSijie.Sun 00fd02c739 正确的hostname 2025-09-26 21:17:14 +08:00
1018 changed files with 263009 additions and 51421 deletions
+43 -54
View File
@@ -1,29 +1,48 @@
[target.x86_64-unknown-linux-musl] # region Native
linker = "rust-lld"
rustflags = ["-C", "linker-flavor=ld.lld"] [target.x86_64-unknown-linux-gnu]
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
[target.aarch64-unknown-linux-gnu] [target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc" rustflags = ["-C", "link-arg=-fuse-ld=mold"]
[target.aarch64-unknown-linux-ohos] [target.'cfg(all(windows, target_env = "msvc"))']
ar = "/usr/local/ohos-sdk/linux/native/llvm/bin/llvm-ar" rustflags = ["-C", "target-feature=+crt-static"]
linker = "/home/runner/sdk/native/llvm/aarch64-unknown-linux-ohos-clang.sh"
[target.aarch64-unknown-linux-ohos.env] [target.wasm32-unknown-unknown]
PKG_CONFIG_PATH = "/usr/local/ohos-sdk/linux/native/sysroot/usr/lib/pkgconfig:/usr/local/ohos-sdk/linux/native/sysroot/usr/local/lib/pkgconfig" rustflags = [
PKG_CONFIG_LIBDIR = "/usr/local/ohos-sdk/linux/native/sysroot/usr/lib:/usr/local/ohos-sdk/linux/native/sysroot/usr/local/lib" "-C",
PKG_CONFIG_SYSROOT_DIR = "/usr/local/ohos-sdk/linux/native/sysroot" "opt-level=z",
SYSROOT = "/usr/local/ohos-sdk/linux/native/sysroot" "--cfg",
'getrandom_backend="wasm_js"',
]
# region
# region CI
[target.x86_64-unknown-linux-musl]
rustflags = ["-C", "target-feature=+crt-static"]
[target.aarch64-unknown-linux-musl] [target.aarch64-unknown-linux-musl]
linker = "aarch64-unknown-linux-musl-gcc"
rustflags = ["-C", "target-feature=+crt-static"] rustflags = ["-C", "target-feature=+crt-static"]
[target.riscv64gc-unknown-linux-musl] [target.riscv64gc-unknown-linux-musl]
linker = "riscv64-unknown-linux-musl-gcc"
rustflags = ["-C", "target-feature=+crt-static"] rustflags = ["-C", "target-feature=+crt-static"]
[target.'cfg(all(windows, target_env = "msvc"))'] [target.armv7-unknown-linux-musleabihf]
rustflags = ["-C", "target-feature=+crt-static"]
[target.armv7-unknown-linux-musleabi]
rustflags = ["-C", "target-feature=+crt-static"]
[target.arm-unknown-linux-musleabihf]
rustflags = ["-C", "target-feature=+crt-static"]
[target.arm-unknown-linux-musleabi]
rustflags = ["-C", "target-feature=+crt-static"]
[target.loongarch64-unknown-linux-musl]
rustflags = ["-C", "target-feature=+crt-static"] rustflags = ["-C", "target-feature=+crt-static"]
[target.mipsel-unknown-linux-musl] [target.mipsel-unknown-linux-musl]
@@ -64,44 +83,14 @@ rustflags = [
"gcc", "gcc",
] ]
[target.armv7-unknown-linux-musleabihf] [target.aarch64-unknown-linux-ohos]
linker = "armv7-unknown-linux-musleabihf-gcc" ar = "/usr/local/ohos-sdk/linux/native/llvm/bin/llvm-ar"
rustflags = ["-C", "target-feature=+crt-static"] linker = "/home/runner/sdk/native/llvm/aarch64-unknown-linux-ohos-clang.sh"
[target.armv7-unknown-linux-musleabi] [target.aarch64-unknown-linux-ohos.env]
linker = "armv7-unknown-linux-musleabi-gcc" PKG_CONFIG_PATH = "/usr/local/ohos-sdk/linux/native/sysroot/usr/lib/pkgconfig:/usr/local/ohos-sdk/linux/native/sysroot/usr/local/lib/pkgconfig"
rustflags = ["-C", "target-feature=+crt-static"] PKG_CONFIG_LIBDIR = "/usr/local/ohos-sdk/linux/native/sysroot/usr/lib:/usr/local/ohos-sdk/linux/native/sysroot/usr/local/lib"
PKG_CONFIG_SYSROOT_DIR = "/usr/local/ohos-sdk/linux/native/sysroot"
SYSROOT = "/usr/local/ohos-sdk/linux/native/sysroot"
[target.loongarch64-unknown-linux-musl] # endregion
linker = "loongarch64-unknown-linux-musl-gcc"
rustflags = ["-C", "target-feature=+crt-static"]
[target.arm-unknown-linux-musleabihf]
linker = "arm-unknown-linux-musleabihf-gcc"
rustflags = [
"-C",
"target-feature=+crt-static",
"-L",
"./musl_gcc/arm-unknown-linux-musleabihf/arm-unknown-linux-musleabihf/lib",
"-L",
"./musl_gcc/arm-unknown-linux-musleabihf/lib/gcc/arm-unknown-linux-musleabihf/15.1.0",
"-l",
"atomic",
"-l",
"gcc",
]
[target.arm-unknown-linux-musleabi]
linker = "arm-unknown-linux-musleabi-gcc"
rustflags = [
"-C",
"target-feature=+crt-static",
"-L",
"./musl_gcc/arm-unknown-linux-musleabi/arm-unknown-linux-musleabi/lib",
"-L",
"./musl_gcc/arm-unknown-linux-musleabi/lib/gcc/arm-unknown-linux-musleabi/15.1.0",
"-l",
"atomic",
"-l",
"gcc",
]
+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
+28
View File
@@ -0,0 +1,28 @@
# Security Policy
[简体中文](SECURITY.zh-CN.md) | English
## Supported Versions
Security updates are provided for the latest EasyTier release. Before reporting a vulnerability, please verify it against the latest release or the current `main` branch when practical.
| Version | Supported |
| --- | --- |
| Latest release | Yes |
| Older releases | No |
## Reporting a Vulnerability
Please report suspected vulnerabilities through GitHub's [private vulnerability reporting form](https://github.com/EasyTier/EasyTier/security/advisories/new).
Do not disclose the vulnerability in a public issue, discussion, or pull request. A useful report should include:
- the affected EasyTier version or commit;
- the affected platform and relevant configuration, with secrets removed;
- a description of the vulnerability and its potential impact;
- reproducible steps or a minimal proof of concept; and
- any known mitigations or workarounds.
The maintainers will assess the report and coordinate remediation and disclosure through the private advisory. Please allow reasonable time for a fix before publishing details.
When investigating, avoid accessing or modifying other users' data, disrupting services, or violating applicable laws.
+28
View File
@@ -0,0 +1,28 @@
# 安全策略
简体中文 | [English](SECURITY.md)
## 支持的版本
EasyTier 仅为最新正式版本提供安全更新。报告漏洞前,请尽可能在最新正式版本或当前 `main` 分支上确认问题仍然存在。
| 版本 | 是否支持 |
| --- | --- |
| 最新正式版本 | 是 |
| 更早版本 | 否 |
## 报告安全漏洞
请通过 GitHub 的[私有漏洞报告表单](https://github.com/EasyTier/EasyTier/security/advisories/new)报告疑似安全漏洞。
请勿在公开 Issue、Discussion 或 Pull Request 中披露漏洞。一份有效的报告应包括:
- 受影响的 EasyTier 版本或提交;
- 受影响的平台和相关配置,请移除其中的密钥等敏感信息;
- 漏洞说明及其潜在影响;
- 可复现的步骤或最小概念验证;
- 已知的缓解措施或临时解决方案。
维护者将评估报告,并通过私有安全公告协调修复和披露。请在公开漏洞详情前为修复预留合理时间。
调查漏洞时,请勿访问或修改其他用户的数据、干扰服务或违反适用的法律法规。
+98
View File
@@ -0,0 +1,98 @@
name: prepare-build
author: Luna
description: Prepare build environment
inputs:
target:
description: 'The target to build for'
required: false
pnpm:
description: 'Whether to run pnpm build'
required: true
default: 'true'
pnpm-build-filter:
description: 'The filter argument for pnpm build (e.g. ./easytier-web/*)'
required: false
default: './easytier-web/*'
gui:
description: 'Whether to prepare the GUI build environment'
required: true
default: 'true'
token:
description: 'GitHub token, used by setup-protoc action'
required: false
runs:
using: 'composite'
steps:
- run: mkdir -p easytier-gui/dist
shell: bash
- name: Install dependencies
if: ${{ runner.os == 'Linux' }}
run: |
sudo apt-get update
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)
if: ${{ inputs.gui == 'true' && runner.os == 'Linux' }}
run: |
sudo apt-get install -qq xdg-utils \
libappindicator3-dev \
libgtk-3-dev \
librsvg2-dev \
libwebkit2gtk-4.1-dev \
libxdo-dev
shell: bash
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: 1.95
target: ${{ !contains(inputs.target, 'mips') && inputs.target || '' }}
components: ${{ contains(inputs.target, 'mips') && 'rust-src' || '' }}
cache: false
rustflags: ''
- name: Install Rust (MIPS)
if: ${{ contains(inputs.target, 'mips') }}
run: |
MUSL_TARGET=${{ inputs.target }}sf
mkdir -p ./musl_gcc
wget --inet4-only -c https://github.com/cross-tools/musl-cross/releases/download/20250520/${MUSL_TARGET}.tar.xz -P ./musl_gcc/
tar xf ./musl_gcc/${MUSL_TARGET}.tar.xz -C ./musl_gcc/
sudo ln -sf $(pwd)/musl_gcc/${MUSL_TARGET}/bin/*gcc /usr/bin/
sudo ln -sf $(pwd)/musl_gcc/${MUSL_TARGET}/include/ /usr/include/musl-cross
sudo ln -sf $(pwd)/musl_gcc/${MUSL_TARGET}/${MUSL_TARGET}/sysroot/ ./musl_gcc/sysroot
sudo chmod -R a+rwx ./musl_gcc
if [[ -d "./musl_gcc/sysroot" ]]; then
echo "BINDGEN_EXTRA_CLANG_ARGS=--sysroot=$(readlink -f ./musl_gcc/sysroot)" >> $GITHUB_ENV
fi
cd "$PWD/musl_gcc/${MUSL_TARGET}/lib/gcc/${MUSL_TARGET}/15.1.0" || exit 255
# for panic-abort
cp libgcc_eh.a libunwind.a
# for mimalloc
ar x libgcc.a _ctzsi2.o _clz.o _bswapsi2.o
ar rcs libctz.a _ctzsi2.o _clz.o _bswapsi2.o
shell: bash
+75
View File
@@ -0,0 +1,75 @@
name: 'Setup pnpm'
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
default: ''
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:
node-version: 22
- name: Install pnpm
uses: pnpm/action-setup@v5
with:
version: 10
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v5
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install and build
shell: bash
run: |
pnpm -r install
if [ -n "${{ inputs.build-filter }}" ]; then
echo "Building with filter: ${{ inputs.build-filter }}"
pnpm -r --workspace-concurrency=1 --filter "${{ inputs.build-filter }}" build
else
echo "No build filter provided, building all packages"
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 # wss
EXPOSE 11012/tcp 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"] ENTRYPOINT ["/sbin/tini", "--", "easytier-core"]
+232 -188
View File
@@ -1,10 +1,16 @@
name: EasyTier Core name: EasyTier Core
on: on:
workflow_dispatch:
push: push:
branches: ["develop", "main", "releases/**"] branches: [ "develop", "main", "releases/**" ]
pull_request: pull_request:
branches: ["develop", "main"] branches: [ "develop", "main" ]
types: [ opened, synchronize, reopened, ready_for_review ]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
@@ -16,99 +22,108 @@ defaults:
jobs: jobs:
pre_job: pre_job:
# continue-on-error: true # Uncomment once integration is finished
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Map a step output to a job output if: github.event_name != 'pull_request' || !github.event.pull_request.draft
env:
RELEVANT_PATHS: >-
["Cargo.toml", "Cargo.lock", "rust-toolchain.toml", ".cargo/**",
"pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json", "easytier/**",
"easytier-core/**", "easytier-proto/**", "easytier-rpc-build/**",
"easytier-web/**", "easytier-contrib/easytier-magisk/**",
".github/workflows/core.yml", ".github/actions/**"]
outputs: outputs:
# do not skip push on branch starts with releases/ # Release builds must run even if they only change release metadata.
should_skip: ${{ steps.skip_check.outputs.should_skip == 'true' && !startsWith(github.ref_name, 'releases/') }} should_skip: >-
${{
(
steps.skip_check.outputs.should_skip == 'true' ||
(
github.event_name == 'pull_request' &&
steps.path_filter.outputs.relevant != 'true'
)
) &&
!startsWith(github.ref_name, 'releases/')
}}
steps: steps:
- id: skip_check - id: skip_check
uses: fkirc/skip-duplicate-actions@v5 uses: fkirc/skip-duplicate-actions@v5
with: with:
# All of these options are optional, so you can remove them if you are happy with the defaults
concurrent_skipping: 'same_content_newer' concurrent_skipping: 'same_content_newer'
skip_after_successful_duplicate: 'true' skip_after_successful_duplicate: 'true'
cancel_others: 'true' cancel_others: 'true'
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", ".github/workflows/core.yml", ".github/workflows/install_rust.sh"]' paths: ${{ env.RELEVANT_PATHS }}
- id: path_filter
if: >-
github.event_name == 'pull_request' &&
steps.skip_check.outputs.should_skip != 'true'
uses: dorny/paths-filter@v4
with:
filters: |
relevant: ${{ env.RELEVANT_PATHS }}
build_web: build_web:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: pre_job needs: pre_job
if: needs.pre_job.outputs.should_skip != 'true' if: needs.pre_job.outputs.should_skip != 'true'
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v5
- uses: actions/setup-node@v4 - name: Setup Frontend Environment
uses: ./.github/actions/prepare-pnpm
with: with:
node-version: 22 token: ${{ github.token }}
build-filter: './easytier-web/*'
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install frontend dependencies
run: |
pnpm -r install
pnpm -r --filter "./easytier-web/*" build
- name: Archive artifact - name: Archive artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
with: with:
name: easytier-web-dashboard name: easytier-web-dashboard
path: | path: |
easytier-web/frontend/dist/* easytier-web/frontend/dist/*
build: build:
strategy: strategy:
fail-fast: false fail-fast: true
matrix: matrix:
include: include:
- TARGET: aarch64-unknown-linux-musl
OS: ubuntu-22.04
ARTIFACT_NAME: linux-aarch64
- TARGET: x86_64-unknown-linux-musl - TARGET: x86_64-unknown-linux-musl
OS: ubuntu-22.04 OS: ubuntu-24.04
ARTIFACT_NAME: linux-x86_64 ARTIFACT_NAME: linux-x86_64
- TARGET: riscv64gc-unknown-linux-musl - TARGET: aarch64-unknown-linux-musl
OS: ubuntu-22.04 OS: ubuntu-24.04-arm
ARTIFACT_NAME: linux-riscv64 ARTIFACT_NAME: linux-aarch64
- TARGET: mips-unknown-linux-musl
OS: ubuntu-22.04
ARTIFACT_NAME: linux-mips
- TARGET: mipsel-unknown-linux-musl
OS: ubuntu-22.04
ARTIFACT_NAME: linux-mipsel
- TARGET: armv7-unknown-linux-musleabihf # raspberry pi 2-3-4, not tested
OS: ubuntu-22.04
ARTIFACT_NAME: linux-armv7hf
- TARGET: armv7-unknown-linux-musleabi # raspberry pi 2-3-4, not tested
OS: ubuntu-22.04
ARTIFACT_NAME: linux-armv7
- TARGET: arm-unknown-linux-musleabihf # raspberry pi 0-1, not tested
OS: ubuntu-22.04
ARTIFACT_NAME: linux-armhf
- TARGET: arm-unknown-linux-musleabi # raspberry pi 0-1, not tested
OS: ubuntu-22.04
ARTIFACT_NAME: linux-arm
- TARGET: riscv64gc-unknown-linux-musl
OS: ubuntu-24.04
ARTIFACT_NAME: linux-riscv64
- TARGET: loongarch64-unknown-linux-musl - TARGET: loongarch64-unknown-linux-musl
OS: ubuntu-24.04 OS: ubuntu-24.04
ARTIFACT_NAME: linux-loongarch64 ARTIFACT_NAME: linux-loongarch64
- TARGET: armv7-unknown-linux-musleabihf # raspberry pi 2-3-4, not tested
OS: ubuntu-24.04
ARTIFACT_NAME: linux-armv7hf
- TARGET: armv7-unknown-linux-musleabi # raspberry pi 2-3-4, not tested
OS: ubuntu-24.04
ARTIFACT_NAME: linux-armv7
- TARGET: arm-unknown-linux-musleabihf # raspberry pi 0-1, not tested
OS: ubuntu-24.04
ARTIFACT_NAME: linux-armhf
- TARGET: arm-unknown-linux-musleabi # raspberry pi 0-1, not tested
OS: ubuntu-24.04
ARTIFACT_NAME: linux-arm
- TARGET: mips-unknown-linux-musl
OS: ubuntu-24.04
ARTIFACT_NAME: linux-mips
- TARGET: mipsel-unknown-linux-musl
OS: ubuntu-24.04
ARTIFACT_NAME: linux-mipsel
- TARGET: x86_64-unknown-freebsd
OS: ubuntu-24.04
ARTIFACT_NAME: freebsd-13.2-x86_64
BSD_VERSION: 13.2
- TARGET: x86_64-apple-darwin - TARGET: x86_64-apple-darwin
OS: macos-latest OS: macos-latest
ARTIFACT_NAME: macos-x86_64 ARTIFACT_NAME: macos-x86_64
@@ -119,17 +134,12 @@ jobs:
- TARGET: x86_64-pc-windows-msvc - TARGET: x86_64-pc-windows-msvc
OS: windows-latest OS: windows-latest
ARTIFACT_NAME: windows-x86_64 ARTIFACT_NAME: windows-x86_64
- TARGET: aarch64-pc-windows-msvc
OS: windows-latest
ARTIFACT_NAME: windows-arm64
- TARGET: i686-pc-windows-msvc - TARGET: i686-pc-windows-msvc
OS: windows-latest OS: windows-latest
ARTIFACT_NAME: windows-i686 ARTIFACT_NAME: windows-i686
- TARGET: aarch64-pc-windows-msvc
- TARGET: x86_64-unknown-freebsd OS: windows-11-arm
OS: ubuntu-22.04 ARTIFACT_NAME: windows-arm64
ARTIFACT_NAME: freebsd-13.2-x86_64
BSD_VERSION: 13.2
runs-on: ${{ matrix.OS }} runs-on: ${{ matrix.OS }}
env: env:
@@ -142,7 +152,7 @@ jobs:
- build_web - build_web
if: needs.pre_job.outputs.should_skip != 'true' if: needs.pre_job.outputs.should_skip != 'true'
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v5
- name: Set current ref as env variable - name: Set current ref as env variable
run: | run: |
@@ -154,158 +164,175 @@ jobs:
name: easytier-web-dashboard name: easytier-web-dashboard
path: easytier-web/frontend/dist/ path: easytier-web/frontend/dist/
- name: Prepare build environment
uses: ./.github/actions/prepare-build
with:
target: ${{ matrix.TARGET }}
gui: true
pnpm: true
token: ${{ secrets.GITHUB_TOKEN }}
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
if: ${{ ! endsWith(matrix.TARGET, 'freebsd') }}
with: with:
# The prefix cache key, this can be changed to start a new cache manually. # The prefix cache key, this can be changed to start a new cache manually.
# default: "v0-rust" # default: "v0-rust"
prefix-key: "" prefix-key: ""
shared-key: "core-registry"
cache-targets: "false"
- uses: mlugg/setup-zig@v2
- name: Setup protoc if: ${{ contains(matrix.OS, 'ubuntu') }}
uses: arduino/setup-protoc@v3
with: with:
# GitHub repo token to use to avoid rate limiter version: 0.16.0
repo-token: ${{ secrets.GITHUB_TOKEN }} use-cache: true
- name: Build Core & Cli - uses: taiki-e/install-action@v2
if: ${{ ! endsWith(matrix.TARGET, 'freebsd') }} if: ${{ contains(matrix.OS, 'ubuntu') }}
run: | with:
bash ./.github/workflows/install_rust.sh # v0.23.3 emits -mcpu=generic+v6+strict_align for
# arm-unknown-linux-musleabi, which zig 0.16.0 rejects;
# unpin only together with a zig bump.
tool: cargo-zigbuild@0.23.2
# loongarch need llvm-18 - name: Build
if [[ $TARGET =~ ^loongarch.*$ ]]; then if: ${{ !contains(matrix.TARGET, 'mips') }}
sudo apt-get install -qq llvm-18 clang-18 run: |
export LLVM_CONFIG_PATH=/usr/lib/llvm-18/bin/llvm-config if [[ "$TARGET" == *windows* ]]; then
fi SUFFIX=.exe
# we set the sysroot when sysroot is a dir
# this dir is a soft link generated by install_rust.sh
# kcp-sys need this to gen ffi bindings. without this clang may fail to find some libc headers such as bits/libc-header-start.h
if [[ -d "./musl_gcc/sysroot" ]]; then
export BINDGEN_EXTRA_CLANG_ARGS=--sysroot=$(readlink -f ./musl_gcc/sysroot)
fi
if [[ $OS =~ ^ubuntu.*$ && $TARGET =~ ^mips.*$ ]]; then
cargo +nightly-2025-09-01 build -r --target $TARGET -Z build-std=std,panic_abort --package=easytier --features=jemalloc
else else
if [[ $OS =~ ^windows.*$ ]]; then SUFFIX=""
SUFFIX=.exe
CORE_FEATURES="--features=mimalloc"
elif [[ $TARGET =~ ^riscv64.*$ || $TARGET =~ ^loongarch64.*$ ]]; then
CORE_FEATURES="--features=mimalloc"
else
CORE_FEATURES="--features=jemalloc"
fi
cargo build --release --target $TARGET --package=easytier-web --features=embed
mv ./target/$TARGET/release/easytier-web"$SUFFIX" ./target/$TARGET/release/easytier-web-embed"$SUFFIX"
cargo build --release --target $TARGET $CORE_FEATURES
fi fi
# Copied and slightly modified from @lmq8267 (https://github.com/lmq8267) if [[ "$TARGET" =~ (x86_64-unknown-linux-musl|aarch64-unknown-linux-musl|windows|darwin) ]]; then
- name: Build Core & Cli (X86_64 FreeBSD) BUILD=build
uses: vmactions/freebsd-vm@v1 else
if: ${{ endsWith(matrix.TARGET, 'freebsd') }} BUILD=zigbuild
fi
if [[ "$TARGET" =~ ^(riscv64|loongarch64|aarch64).*$ || "$TARGET" =~ (freebsd|windows) ]]; then
FEATURES="mimalloc"
else
FEATURES="jemalloc"
fi
cargo $BUILD --release --target $TARGET --package=easytier-web --features=embed
mv ./target/$TARGET/release/easytier-web"$SUFFIX" ./target/$TARGET/release/easytier-web-embed"$SUFFIX"
cargo $BUILD --release --target $TARGET --features=$FEATURES
- name: Build (MIPS)
if: ${{ contains(matrix.TARGET, 'mips') }}
env: env:
TARGET: ${{ matrix.TARGET }} RUSTC_BOOTSTRAP: 1
with: run: |
envs: TARGET cargo build -r --target $TARGET -Z build-std=std,panic_abort --package=easytier --features=jemalloc
release: ${{ matrix.BSD_VERSION }}
arch: x86_64
usesh: true
mem: 6144
cpu: 4
run: |
uname -a
echo $SHELL
pwd
ls -lah
whoami
env | sort
pkg install -y git protobuf llvm-devel sudo curl
curl --proto 'https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
. $HOME/.cargo/env
rustup set auto-self-update disable
rustup install 1.89
rustup default 1.89
export CC=clang
export CXX=clang++
export CARGO_TERM_COLOR=always
cargo build --release --verbose --target $TARGET --package=easytier-web --features=embed
mv ./target/$TARGET/release/easytier-web ./target/$TARGET/release/easytier-web-embed
cargo build --release --verbose --target $TARGET --features=mimalloc
- name: Compress - name: Compress
run: | run: |
mkdir -p ./artifacts/objects/ mkdir -p ./artifacts/objects/
# windows is the only OS using a different convention for executable file name # windows is the only OS using a different convention for executable file name
if [[ $OS =~ ^windows.*$ && $TARGET =~ ^x86_64.*$ ]]; then if [[ $OS =~ ^windows.*$ ]]; then
SUFFIX=.exe SUFFIX=.exe
cp easytier/third_party/*.dll ./artifacts/objects/ case $TARGET in
elif [[ $OS =~ ^windows.*$ && $TARGET =~ ^i686.*$ ]]; then x86_64*) ARCH_DIR=x86_64 ;;
SUFFIX=.exe i686*) ARCH_DIR=i686 ;;
cp easytier/third_party/i686/*.dll ./artifacts/objects/ aarch64*) ARCH_DIR=arm64 ;;
elif [[ $OS =~ ^windows.*$ && $TARGET =~ ^aarch64.*$ ]]; then esac
SUFFIX=.exe if [[ -n "$ARCH_DIR" ]]; then
cp easytier/third_party/arm64/*.dll ./artifacts/objects/ find "easytier/third_party/${ARCH_DIR}" -maxdepth 1 -type f \( -name "*.dll" -o -name "*.sys" \) -exec cp {} ./artifacts/objects/ \;
fi
fi fi
if [[ $GITHUB_REF_TYPE =~ ^tag$ ]]; then if [[ $GITHUB_REF_TYPE =~ ^tag$ ]]; then
TAG=$GITHUB_REF_NAME TAG=$GITHUB_REF_NAME
else else
TAG=$GITHUB_SHA TAG=$GITHUB_SHA
fi fi
if [[ $OS =~ ^ubuntu.*$ && ! $TARGET =~ (loongarch|freebsd) ]]; then
HOST_ARCH=$(uname -m)
case $HOST_ARCH in
x86_64) UPX_ARCH="amd64" ;;
aarch64) UPX_ARCH="arm64" ;;
*) UPX_ARCH="amd64" ;;
esac
if [[ $OS =~ ^ubuntu.*$ && ! $TARGET =~ ^.*freebsd$ && ! $TARGET =~ ^loongarch.*$ && ! $TARGET =~ ^riscv64.*$ ]]; then
UPX_VERSION=4.2.4 UPX_VERSION=4.2.4
curl -L https://github.com/upx/upx/releases/download/v${UPX_VERSION}/upx-${UPX_VERSION}-amd64_linux.tar.xz -s | tar xJvf - UPX_PKG="upx-${UPX_VERSION}-${UPX_ARCH}_linux"
cp upx-${UPX_VERSION}-amd64_linux/upx . curl -L "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/${UPX_PKG}.tar.xz" -s | tar xJvf -
./upx --lzma --best ./target/$TARGET/release/easytier-core"$SUFFIX" cp "${UPX_PKG}/upx" .
./upx --lzma --best ./target/$TARGET/release/easytier-cli"$SUFFIX" UPX_BIN=./upx
fi fi
mv ./target/$TARGET/release/easytier-core"$SUFFIX" ./artifacts/objects/ for BIN in ./target/$TARGET/release/easytier-{core,cli,web,web-embed}"$SUFFIX"; do
mv ./target/$TARGET/release/easytier-cli"$SUFFIX" ./artifacts/objects/ if [[ -f "$BIN" ]]; then
if [[ ! $TARGET =~ ^mips.*$ ]]; then if [[ -n "$UPX_BIN" ]]; then
mv ./target/$TARGET/release/easytier-web"$SUFFIX" ./artifacts/objects/ $UPX_BIN --lzma --best "$BIN" || true
mv ./target/$TARGET/release/easytier-web-embed"$SUFFIX" ./artifacts/objects/ fi
fi
mv "$BIN" ./artifacts/objects/
fi
done
mv ./artifacts/objects/* ./artifacts/ mv ./artifacts/objects/* ./artifacts/
rm -rf ./artifacts/objects/ rm -rf ./artifacts/objects/
- name: Archive artifact - name: Archive artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
with: with:
name: easytier-${{ matrix.ARTIFACT_NAME }} name: easytier-${{ matrix.ARTIFACT_NAME }}
path: | path: |
./artifacts/* ./artifacts/*
core-result: - name: Prepare SignPath test artifact
if: needs.pre_job.outputs.should_skip != 'true' && always() if: ${{ github.event_name == 'workflow_dispatch' && contains(matrix.TARGET, 'windows') }}
runs-on: ubuntu-latest run: |
needs: mkdir -p ./signpath-artifacts
- pre_job cp \
- build_web ./artifacts/easytier-core.exe \
- build ./artifacts/easytier-cli.exe \
steps: ./artifacts/easytier-web.exe \
- name: Mark result as failed ./artifacts/easytier-web-embed.exe \
if: needs.build.result != 'success' ./signpath-artifacts/
run: exit 1
magisk_build: - name: Upload unsigned SignPath test artifact
needs: if: ${{ github.event_name == 'workflow_dispatch' && contains(matrix.TARGET, 'windows') }}
- pre_job id: signpath-upload
- build_web uses: actions/upload-artifact@v5
- build with:
if: needs.pre_job.outputs.should_skip != 'true' && always() name: signpath-unsigned-${{ matrix.ARTIFACT_NAME }}
path: ./signpath-artifacts/*
if-no-files-found: error
- name: Submit SignPath test-signing request
if: ${{ github.event_name == 'workflow_dispatch' && contains(matrix.TARGET, 'windows') }}
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: 368cf38d-86d5-4bfe-a1fa-82a50dce7e9f
project-slug: EasyTier
signing-policy-slug: test-signing
artifact-configuration-slug: initial
github-artifact-id: ${{ steps.signpath-upload.outputs.artifact-id }}
wait-for-completion: true
output-artifact-directory: ./signpath-signed-artifacts
- name: Upload signed SignPath test artifact
if: ${{ github.event_name == 'workflow_dispatch' && contains(matrix.TARGET, 'windows') }}
uses: actions/upload-artifact@v5
with:
name: signpath-signed-${{ matrix.ARTIFACT_NAME }}
path: ./signpath-signed-artifacts/*
if-no-files-found: error
build_magisk:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [ pre_job, build_web, build ]
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
steps: steps:
- name: Checkout Code - name: Checkout Code
uses: actions/checkout@v4 # 必须先检出代码才能获取模块配置 uses: actions/checkout@v5 # 必须先检出代码才能获取模块配置
# 下载二进制文件到独立目录 # 下载二进制文件到独立目录
- name: Download Linux aarch64 binaries - name: Download Linux aarch64 binaries
@@ -314,18 +341,26 @@ jobs:
name: easytier-linux-aarch64 name: easytier-linux-aarch64
path: ./downloaded-binaries/ # 独立目录避免冲突 path: ./downloaded-binaries/ # 独立目录避免冲突
# 将二进制文件复制到 Magisk 模块目录 - name: Download Magisk WebUI
- name: Prepare binaries uses: actions/download-artifact@v4
run: | with:
mkdir -p ./easytier-contrib/easytier-magisk/ name: easytier-web-dashboard
cp ./downloaded-binaries/easytier-core ./easytier-contrib/easytier-magisk/ path: ./downloaded-webui/
cp ./downloaded-binaries/easytier-cli ./easytier-contrib/easytier-magisk/
cp ./downloaded-binaries/easytier-web ./easytier-contrib/easytier-magisk/
# 将二进制文件和 WebUI 复制到 Magisk 模块目录
- name: Prepare Magisk module
run: |
module_dir=./easytier-contrib/easytier-magisk
mkdir -p "$module_dir/webroot"
cp ./downloaded-binaries/easytier-core "$module_dir/"
cp ./downloaded-binaries/easytier-cli "$module_dir/"
cp ./downloaded-binaries/easytier-web "$module_dir/"
cp -R ./downloaded-webui/config-generator/. "$module_dir/webroot/"
test -f "$module_dir/webroot/index.html"
# 上传生成的模块 # 上传生成的模块
- name: Upload Magisk Module - name: Upload Magisk Module
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
with: with:
name: Easytier-Magisk name: Easytier-Magisk
path: | path: |
@@ -333,3 +368,12 @@ jobs:
!./easytier-contrib/easytier-magisk/build.sh !./easytier-contrib/easytier-magisk/build.sh
!./easytier-contrib/easytier-magisk/magisk_update.json !./easytier-contrib/easytier-magisk/magisk_update.json
if-no-files-found: error if-no-files-found: error
core-result:
runs-on: ubuntu-latest
needs: [ pre_job, build_web, build, build_magisk ]
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
steps:
- name: Mark result as failed
if: contains(needs.*.result, 'failure')
run: exit 1
+2 -2
View File
@@ -11,7 +11,7 @@ on:
image_tag: image_tag:
description: 'Tag for this image build' description: 'Tag for this image build'
type: string type: string
default: 'v2.4.5' default: 'v2.7.0'
required: true required: true
mark_latest: mark_latest:
description: 'Mark this image as latest' description: 'Mark this image as latest'
@@ -31,7 +31,7 @@ jobs:
steps: steps:
- -
name: Checkout name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v5
- -
name: Validate inputs name: Validate inputs
run: | run: |
+44
View File
@@ -0,0 +1,44 @@
name: EasyTier Go
on:
push:
branches: ["develop", "main", "releases/**"]
paths:
- "easytier-go/**"
- ".github/workflows/go.yml"
pull_request:
branches: ["develop", "main"]
types: [opened, synchronize, reopened, ready_for_review]
paths:
- "easytier-go/**"
- ".github/workflows/go.yml"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
name: Test Go modules
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version: "1.25.x"
cache-dependency-path: |
easytier-go/go.sum
easytier-go/examples/tun/go.sum
- name: Test main module
working-directory: easytier-go
run: go test ./...
- name: Test TUN example module
working-directory: easytier-go/examples/tun
run: go test ./...
+158 -117
View File
@@ -5,7 +5,12 @@ on:
branches: ["develop", "main", "releases/**"] branches: ["develop", "main", "releases/**"]
pull_request: pull_request:
branches: ["develop", "main"] branches: ["develop", "main"]
types: [opened, synchronize, reopened, ready_for_review]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
@@ -16,33 +21,60 @@ defaults:
jobs: jobs:
pre_job: pre_job:
# continue-on-error: true # Uncomment once integration is finished
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Map a step output to a job output if: github.event_name != 'pull_request' || !github.event.pull_request.draft
env:
RELEVANT_PATHS: >-
["Cargo.toml", "Cargo.lock", "rust-toolchain.toml", ".cargo/**",
"pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json", "easytier/**",
"easytier-core/**", "easytier-proto/**", "easytier-rpc-build/**",
"easytier-gui/**", "tauri-plugin-vpnservice/**",
"easytier-web/frontend-lib/**", ".github/workflows/gui.yml",
".github/actions/**"]
outputs: outputs:
should_skip: ${{ steps.skip_check.outputs.should_skip == 'true' && !startsWith(github.ref_name, 'releases/') }} # Release builds must run even if they only change release metadata.
should_skip: >-
${{
(
steps.skip_check.outputs.should_skip == 'true' ||
(
github.event_name == 'pull_request' &&
steps.path_filter.outputs.relevant != 'true'
)
) &&
!startsWith(github.ref_name, 'releases/')
}}
steps: steps:
- id: skip_check - id: skip_check
uses: fkirc/skip-duplicate-actions@v5 uses: fkirc/skip-duplicate-actions@v5
with: with:
# All of these options are optional, so you can remove them if you are happy with the defaults
concurrent_skipping: 'same_content_newer' concurrent_skipping: 'same_content_newer'
skip_after_successful_duplicate: 'true' skip_after_successful_duplicate: 'true'
cancel_others: 'true' cancel_others: 'true'
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-gui/**", ".github/workflows/gui.yml", ".github/workflows/install_rust.sh", ".github/workflows/install_gui_dep.sh"]' paths: ${{ env.RELEVANT_PATHS }}
- id: path_filter
if: >-
github.event_name == 'pull_request' &&
steps.skip_check.outputs.should_skip != 'true'
uses: dorny/paths-filter@v4
with:
filters: |
relevant: ${{ env.RELEVANT_PATHS }}
build-gui: build-gui:
strategy: strategy:
fail-fast: false fail-fast: true
matrix: matrix:
include: include:
- TARGET: aarch64-unknown-linux-musl
OS: ubuntu-22.04
GUI_TARGET: aarch64-unknown-linux-gnu
ARTIFACT_NAME: linux-aarch64
- TARGET: x86_64-unknown-linux-musl - TARGET: x86_64-unknown-linux-musl
OS: ubuntu-22.04 OS: ubuntu-24.04
GUI_TARGET: x86_64-unknown-linux-gnu GUI_TARGET: x86_64-unknown-linux-gnu
ARTIFACT_NAME: linux-x86_64 ARTIFACT_NAME: linux-x86_64
- TARGET: aarch64-unknown-linux-musl
OS: ubuntu-24.04-arm
GUI_TARGET: aarch64-unknown-linux-gnu
ARTIFACT_NAME: linux-aarch64
- TARGET: x86_64-apple-darwin - TARGET: x86_64-apple-darwin
OS: macos-latest OS: macos-latest
@@ -57,16 +89,14 @@ jobs:
OS: windows-latest OS: windows-latest
GUI_TARGET: x86_64-pc-windows-msvc GUI_TARGET: x86_64-pc-windows-msvc
ARTIFACT_NAME: windows-x86_64 ARTIFACT_NAME: windows-x86_64
- TARGET: aarch64-pc-windows-msvc
OS: windows-latest
GUI_TARGET: aarch64-pc-windows-msvc
ARTIFACT_NAME: windows-arm64
- TARGET: i686-pc-windows-msvc - TARGET: i686-pc-windows-msvc
OS: windows-latest OS: windows-latest
GUI_TARGET: i686-pc-windows-msvc GUI_TARGET: i686-pc-windows-msvc
ARTIFACT_NAME: windows-i686 ARTIFACT_NAME: windows-i686
- TARGET: aarch64-pc-windows-msvc
OS: windows-11-arm
GUI_TARGET: aarch64-pc-windows-msvc
ARTIFACT_NAME: windows-arm64
runs-on: ${{ matrix.OS }} runs-on: ${{ matrix.OS }}
env: env:
@@ -78,114 +108,128 @@ jobs:
needs: pre_job needs: pre_job
if: needs.pre_job.outputs.should_skip != 'true' if: needs.pre_job.outputs.should_skip != 'true'
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v5
- name: Install GUI dependencies (x86 only)
if: ${{ matrix.TARGET == 'x86_64-unknown-linux-musl' }}
run: bash ./.github/workflows/install_gui_dep.sh
- name: Install GUI cross compile (aarch64 only)
if: ${{ matrix.TARGET == 'aarch64-unknown-linux-musl' }}
run: |
# see https://tauri.app/v1/guides/building/linux/
echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy main restricted" | sudo tee /etc/apt/sources.list
echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy-updates main restricted" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy universe" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy-updates universe" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy multiverse" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy-updates multiverse" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy-backports main restricted universe multiverse" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=amd64] http://security.ubuntu.com/ubuntu/ jammy-security main restricted" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=amd64] http://security.ubuntu.com/ubuntu/ jammy-security universe" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=amd64] http://security.ubuntu.com/ubuntu/ jammy-security multiverse" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy main restricted" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-updates main restricted" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy universe" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-updates universe" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy multiverse" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-updates multiverse" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-backports main restricted universe multiverse" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-security main restricted" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-security universe" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-security multiverse" | sudo tee -a /etc/apt/sources.list
sudo dpkg --add-architecture arm64
sudo apt update
sudo apt install aptitude
sudo aptitude install -y libgstreamer1.0-0:arm64 gstreamer1.0-plugins-base:arm64 gstreamer1.0-plugins-good:arm64 \
libgstreamer-gl1.0-0:arm64 libgstreamer-plugins-base1.0-0:arm64 libgstreamer-plugins-good1.0-0:arm64 libwebkit2gtk-4.1-0:arm64 \
libwebkit2gtk-4.1-dev:arm64 libssl-dev:arm64 gcc-aarch64-linux-gnu
echo "PKG_CONFIG_SYSROOT_DIR=/usr/aarch64-linux-gnu/" >> "$GITHUB_ENV"
echo "PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig/" >> "$GITHUB_ENV"
- name: Set current ref as env variable - name: Set current ref as env variable
run: | run: |
echo "GIT_DESC=$(git log -1 --format=%cd.%h --date=format:%Y-%m-%d_%H:%M:%S)" >> $GITHUB_ENV echo "GIT_DESC=$(git log -1 --format=%cd.%h --date=format:%Y-%m-%d_%H:%M:%S)" >> $GITHUB_ENV
- uses: actions/setup-node@v4 - name: Prepare build environment
uses: ./.github/actions/prepare-build
with: with:
node-version: 22 target: ${{ matrix.TARGET }}
gui: true
- name: Install pnpm pnpm: true
uses: pnpm/action-setup@v4 pnpm-build-filter: ''
with: token: ${{ secrets.GITHUB_TOKEN }}
version: 10
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install frontend dependencies
run: |
pnpm -r install
pnpm -r build
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
with: with:
# The prefix cache key, this can be changed to start a new cache manually. # The prefix cache key, this can be changed to start a new cache manually.
# default: "v0-rust" # default: "v0-rust"
prefix-key: "" prefix-key: ""
shared-key: "gui-registry"
- name: Install rust target cache-targets: "false"
run: bash ./.github/workflows/install_rust.sh
- name: Setup protoc
uses: arduino/setup-protoc@v3
with:
# GitHub repo token to use to avoid rate limiter
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: copy correct DLLs - name: copy correct DLLs
if: ${{ matrix.OS == 'windows-latest' }} if: ${{ contains(matrix.GUI_TARGET, 'windows') }}
run: | run: |
if [[ $GUI_TARGET =~ ^aarch64.*$ ]]; then case $TARGET in
cp ./easytier/third_party/arm64/*.dll ./easytier-gui/src-tauri/ x86_64*) ARCH_DIR=x86_64 ;;
elif [[ $GUI_TARGET =~ ^i686.*$ ]]; then i686*) ARCH_DIR=i686 ;;
cp ./easytier/third_party/i686/*.dll ./easytier-gui/src-tauri/ aarch64*) ARCH_DIR=arm64 ;;
else esac
cp ./easytier/third_party/*.dll ./easytier-gui/src-tauri/ if [[ -n "$ARCH_DIR" ]]; then
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 fi
- name: Build GUI - 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 uses: tauri-apps/tauri-action@v0
with: with:
projectPath: ./easytier-gui projectPath: ./easytier-gui
# https://tauri.app/v1/guides/building/linux/#cross-compiling-tauri-applications-for-arm-based-devices args: --verbose --target ${{ matrix.GUI_TARGET }}
args: --verbose --target ${{ matrix.GUI_TARGET }} ${{ matrix.OS == 'ubuntu-22.04' && contains(matrix.TARGET, 'aarch64') && '--bundles deb' || '' }}
- name: Compress - 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: | run: |
mkdir -p ./artifacts/objects/ mkdir -p ./artifacts/objects/
@@ -194,36 +238,33 @@ jobs:
else else
TAG=$GITHUB_SHA TAG=$GITHUB_SHA
fi fi
# copy gui bundle, gui is built without specific target # copy gui bundle, gui is built without specific target
if [[ $OS =~ ^windows.*$ ]]; then if [[ $GUI_TARGET =~ windows ]]; then
mv ./target/$GUI_TARGET/release/bundle/nsis/*.exe ./artifacts/objects/ mv ./target/$GUI_TARGET/release/bundle/nsis/*.exe ./artifacts/objects/
elif [[ $OS =~ ^macos.*$ ]]; then elif [[ $GUI_TARGET =~ darwin ]]; then
mv ./target/$GUI_TARGET/release/bundle/dmg/*.dmg ./artifacts/objects/ mv ./target/$GUI_TARGET/release/bundle/dmg/*.dmg ./artifacts/objects/
elif [[ $OS =~ ^ubuntu.*$ && ! $TARGET =~ ^mips.*$ ]]; then elif [[ $GUI_TARGET =~ linux ]]; then
mv ./target/$GUI_TARGET/release/bundle/deb/*.deb ./artifacts/objects/ mv ./target/$GUI_TARGET/release/bundle/deb/*.deb ./artifacts/objects/
if [[ $GUI_TARGET =~ ^x86_64.*$ ]]; then mv ./target/$GUI_TARGET/release/bundle/rpm/*.rpm ./artifacts/objects/
# currently only x86 appimage is supported mv ./target/$GUI_TARGET/release/bundle/appimage/*.AppImage ./artifacts/objects/
mv ./target/$GUI_TARGET/release/bundle/appimage/*.AppImage ./artifacts/objects/
fi
fi fi
mv ./artifacts/objects/* ./artifacts/ mv ./artifacts/objects/* ./artifacts/
rm -rf ./artifacts/objects/ rm -rf ./artifacts/objects/
- name: Archive artifact - name: Archive artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
with: with:
name: easytier-gui-${{ matrix.ARTIFACT_NAME }} name: easytier-gui-${{ matrix.ARTIFACT_NAME }}
path: | path: |
./artifacts/* ./artifacts/*
gui-result: gui-result:
if: needs.pre_job.outputs.should_skip != 'true' && always()
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: needs: [ pre_job, build-gui ]
- pre_job if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
- build-gui
steps: steps:
- name: Mark result as failed - name: Mark result as failed
if: needs.build-gui.result != 'success' if: contains(needs.*.result, 'failure')
run: exit 1 run: exit 1
-11
View File
@@ -1,11 +0,0 @@
sudo apt update
sudo apt install -qq libwebkit2gtk-4.1-dev \
build-essential \
curl \
wget \
file \
libgtk-3-dev \
librsvg2-dev \
libxdo-dev \
libssl-dev \
patchelf
-61
View File
@@ -1,61 +0,0 @@
#!/usr/bin/env bash
# env needed:
# - TARGET
# - GUI_TARGET
# - OS
# dependencies are only needed on ubuntu as that's the only place where
# we make cross-compilation
if [[ $OS =~ ^ubuntu.*$ ]]; then
sudo apt-get update && sudo apt-get install -qq musl-tools libappindicator3-dev llvm clang
# https://github.com/cross-tools/musl-cross/releases
# if "musl" is a substring of TARGET, we assume that we are using musl
MUSL_TARGET=$TARGET
# if target is mips or mipsel, we should use soft-float version of musl
if [[ $TARGET =~ ^mips.*$ || $TARGET =~ ^mipsel.*$ ]]; then
MUSL_TARGET=${TARGET}sf
elif [[ $TARGET =~ ^riscv64gc-.*$ ]]; then
MUSL_TARGET=${TARGET/#riscv64gc-/riscv64-}
fi
if [[ $MUSL_TARGET =~ musl ]]; then
mkdir -p ./musl_gcc
wget --inet4-only -c https://github.com/cross-tools/musl-cross/releases/download/20250520/${MUSL_TARGET}.tar.xz -P ./musl_gcc/
tar xf ./musl_gcc/${MUSL_TARGET}.tar.xz -C ./musl_gcc/
sudo ln -sf $(pwd)/musl_gcc/${MUSL_TARGET}/bin/*gcc /usr/bin/
sudo ln -sf $(pwd)/musl_gcc/${MUSL_TARGET}/include/ /usr/include/musl-cross
sudo ln -sf $(pwd)/musl_gcc/${MUSL_TARGET}/${MUSL_TARGET}/sysroot/ ./musl_gcc/sysroot
sudo chmod -R a+rwx ./musl_gcc
fi
fi
# see https://github.com/rust-lang/rustup/issues/3709
rustup set auto-self-update disable
rustup install 1.89
rustup default 1.89
# mips/mipsel cannot add target from rustup, need compile by ourselves
if [[ $OS =~ ^ubuntu.*$ && $TARGET =~ ^mips.*$ ]]; then
cd "$PWD/musl_gcc/${MUSL_TARGET}/lib/gcc/${MUSL_TARGET}/15.1.0" || exit 255
# for panic-abort
cp libgcc_eh.a libunwind.a
# for mimalloc
ar x libgcc.a _ctzsi2.o _clz.o _bswapsi2.o
ar rcs libctz.a _ctzsi2.o _clz.o _bswapsi2.o
rustup toolchain install nightly-2025-09-01-x86_64-unknown-linux-gnu
rustup component add rust-src --toolchain nightly-2025-09-01-x86_64-unknown-linux-gnu
# https://github.com/rust-lang/rust/issues/128808
# remove it after Cargo or rustc fix this.
RUST_LIB_SRC=$HOME/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/
if [[ -f $RUST_LIB_SRC/library/Cargo.lock && ! -f $RUST_LIB_SRC/Cargo.lock ]]; then
cp -f $RUST_LIB_SRC/library/Cargo.lock $RUST_LIB_SRC/Cargo.lock
fi
else
rustup target add $TARGET
if [[ $GUI_TARGET != '' ]]; then
rustup target add $GUI_TARGET
fi
fi
+62
View File
@@ -0,0 +1,62 @@
name: EasyTier JavaScript Hosts
on:
push:
branches: ["develop", "main", "releases/**"]
paths:
- "Cargo.toml"
- "Cargo.lock"
- "easytier-core/**"
- "easytier-proto/**"
- "easytier-js/**"
- ".github/workflows/js.yml"
pull_request:
branches: ["develop", "main"]
types: [opened, synchronize, reopened, ready_for_review]
paths:
- "Cargo.toml"
- "Cargo.lock"
- "easytier-core/**"
- "easytier-proto/**"
- "easytier-js/**"
- ".github/workflows/js.yml"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: 1.95
target: wasm32-wasip1
cache: false
rustflags: ''
- uses: arduino/setup-protoc@v3
with:
version: '35.1'
repo-token: ${{ github.token }}
- uses: actions/setup-node@v5
with:
node-version: 22
- uses: pnpm/action-setup@v5
with:
version: 10
run_install: false
- name: Install dependencies
run: pnpm --dir easytier-js install --frozen-lockfile
- name: Build and test
run: pnpm --dir easytier-js check
+72 -68
View File
@@ -5,7 +5,12 @@ on:
branches: ["develop", "main", "releases/**"] branches: ["develop", "main", "releases/**"]
pull_request: pull_request:
branches: ["develop", "main"] branches: ["develop", "main"]
types: [opened, synchronize, reopened, ready_for_review]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
@@ -16,38 +21,70 @@ defaults:
jobs: jobs:
pre_job: pre_job:
# continue-on-error: true # Uncomment once integration is finished
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Map a step output to a job output if: github.event_name != 'pull_request' || !github.event.pull_request.draft
env:
RELEVANT_PATHS: >-
["Cargo.toml", "Cargo.lock", "rust-toolchain.toml", ".cargo/**",
"pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json", "easytier/**",
"easytier-core/**", "easytier-proto/**", "easytier-rpc-build/**",
"easytier-gui/**", "tauri-plugin-vpnservice/**",
"easytier-web/frontend-lib/**", ".github/workflows/mobile.yml",
".github/actions/**"]
outputs: outputs:
should_skip: ${{ steps.skip_check.outputs.should_skip == 'true' && !startsWith(github.ref_name, 'releases/') }} # Release builds must run even if they only change release metadata.
should_skip: >-
${{
(
steps.skip_check.outputs.should_skip == 'true' ||
(
github.event_name == 'pull_request' &&
steps.path_filter.outputs.relevant != 'true'
)
) &&
!startsWith(github.ref_name, 'releases/')
}}
steps: steps:
- id: skip_check - id: skip_check
uses: fkirc/skip-duplicate-actions@v5 uses: fkirc/skip-duplicate-actions@v5
with: with:
# All of these options are optional, so you can remove them if you are happy with the defaults
concurrent_skipping: 'same_content_newer' concurrent_skipping: 'same_content_newer'
skip_after_successful_duplicate: 'true' skip_after_successful_duplicate: 'true'
cancel_others: 'true' cancel_others: 'true'
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-gui/**", "tauri-plugin-vpnservice/**", ".github/workflows/mobile.yml", ".github/workflows/install_rust.sh"]' paths: ${{ env.RELEVANT_PATHS }}
- id: path_filter
if: >-
github.event_name == 'pull_request' &&
steps.skip_check.outputs.should_skip != 'true'
uses: dorny/paths-filter@v4
with:
filters: |
relevant: ${{ env.RELEVANT_PATHS }}
build-mobile: build-mobile:
strategy: strategy:
fail-fast: false fail-fast: true
matrix: matrix:
include: include:
- TARGET: android - TARGET: aarch64-linux-android
OS: ubuntu-22.04 ARCH: aarch64
ARTIFACT_NAME: android - TARGET: armv7-linux-androideabi
runs-on: ${{ matrix.OS }} ARCH: armv7
- TARGET: i686-linux-android
ARCH: i686
- TARGET: x86_64-linux-android
ARCH: x86_64
runs-on: ubuntu-latest
env: env:
NAME: easytier NAME: easytier
TARGET: ${{ matrix.TARGET }} TARGET: ${{ matrix.TARGET }}
OS: ${{ matrix.OS }} ARCH: ${{ matrix.ARCH }}
OSS_BUCKET: ${{ secrets.ALIYUN_OSS_BUCKET }} OSS_BUCKET: ${{ secrets.ALIYUN_OSS_BUCKET }}
needs: pre_job needs: pre_job
if: needs.pre_job.outputs.should_skip != 'true' if: needs.pre_job.outputs.should_skip != 'true'
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v5
- name: Set current ref as env variable - name: Set current ref as env variable
run: | run: |
@@ -61,72 +98,41 @@ jobs:
- name: Setup Android SDK - name: Setup Android SDK
uses: android-actions/setup-android@v3 uses: android-actions/setup-android@v3
with: with:
cmdline-tools-version: 11076708 cmdline-tools-version: 12.0
packages: 'build-tools;34.0.0 ndk;26.0.10792818 tools platform-tools platforms;android-34 ' packages: 'build-tools;34.0.0 ndk;26.0.10792818 platform-tools platforms;android-34 '
- name: Setup Android Environment - name: Setup Android Environment
run: | run: |
echo "$ANDROID_HOME/platform-tools" >> $GITHUB_PATH echo "$ANDROID_HOME/platform-tools" >> $GITHUB_PATH
echo "$ANDROID_HOME/ndk/26.0.10792818/toolchains/llvm/prebuilt/linux-x86_64/bin" >> $GITHUB_PATH echo "$ANDROID_HOME/ndk/26.0.10792818/toolchains/llvm/prebuilt/linux-x86_64/bin" >> $GITHUB_PATH
echo "NDK_HOME=$ANDROID_HOME/ndk/26.0.10792818/" > $GITHUB_ENV echo "NDK_HOME=$ANDROID_HOME/ndk/26.0.10792818/" >> $GITHUB_ENV
- uses: actions/setup-node@v4 - name: Prepare build environment
uses: ./.github/actions/prepare-build
with: with:
node-version: 22 target: ${{ matrix.TARGET }}
gui: false
- name: Install pnpm pnpm: true
uses: pnpm/action-setup@v4 pnpm-build-filter: ''
with: token: ${{ secrets.GITHUB_TOKEN }}
version: 10
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install frontend dependencies
run: |
pnpm -r install
pnpm -r build
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
with: with:
# The prefix cache key, this can be changed to start a new cache manually. # The prefix cache key, this can be changed to start a new cache manually.
# default: "v0-rust" # default: "v0-rust"
prefix-key: "" prefix-key: ""
shared-key: "gui-registry"
cache-targets: "false"
- name: Install rust target - name: Build
run: |
bash ./.github/workflows/install_rust.sh
rustup target add aarch64-linux-android
rustup target add armv7-linux-androideabi
rustup target add i686-linux-android
rustup target add x86_64-linux-android
- name: Setup protoc
uses: arduino/setup-protoc@v3
with:
# GitHub repo token to use to avoid rate limiter
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Build Android
run: | run: |
cd easytier-gui cd easytier-gui
pnpm tauri android build pnpm tauri android build --apk --target "$ARCH" --split-per-abi
- name: Compress - name: Collect artifact
run: | run: |
mkdir -p ./artifacts/objects/ mkdir -p ./artifacts/objects/
mv easytier-gui/src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk ./artifacts/objects/ mv easytier-gui/src-tauri/gen/android/app/build/outputs/apk/*/release/*.apk ./artifacts/objects/
if [[ $GITHUB_REF_TYPE =~ ^tag$ ]]; then if [[ $GITHUB_REF_TYPE =~ ^tag$ ]]; then
TAG=$GITHUB_REF_NAME TAG=$GITHUB_REF_NAME
@@ -134,23 +140,21 @@ jobs:
TAG=$GITHUB_SHA TAG=$GITHUB_SHA
fi fi
mv ./artifacts/objects/* ./artifacts mv ./artifacts/objects/* ./artifacts/
rm -rf ./artifacts/objects/ rm -rf ./artifacts/objects/
- name: Archive artifact - name: Archive artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
with: with:
name: easytier-gui-${{ matrix.ARTIFACT_NAME }} name: easytier-mobile-android-${{ matrix.ARCH }}
path: | path: |
./artifacts/* ./artifacts/*
mobile-result: mobile-result:
if: needs.pre_job.outputs.should_skip != 'true' && always()
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: needs: [ pre_job, build-mobile ]
- pre_job if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
- build-mobile
steps: steps:
- name: Mark result as failed - name: Mark result as failed
if: needs.build-mobile.result != 'success' if: contains(needs.*.result, 'failure')
run: exit 1 run: exit 1
+44
View File
@@ -0,0 +1,44 @@
name: Nix Check
on:
push:
branches: ["main", "develop"]
paths:
- "**/*.nix"
- "flake.lock"
- "rust-toolchain.toml"
pull_request:
branches: ["main", "develop"]
types: [opened, synchronize, reopened, ready_for_review]
paths:
- "**/*.nix"
- "flake.lock"
- "rust-toolchain.toml"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
check-full-shell:
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install Nix
uses: cachix/install-nix-action@v27
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Magic Nix Cache
uses: DeterminateSystems/magic-nix-cache-action@v6
- name: Warm up full devShell
run: nix develop .#full --command true
- name: Cargo check in flake environment
run: nix develop .#full --command cargo check
- name: Cargo build in flake environment
run: nix develop .#full --command cargo build
+211 -85
View File
@@ -1,114 +1,240 @@
name: EasyTier OHOS name: ohos
on: on:
push: push:
branches: ["develop", "main", "releases/**"] branches: [develop, main, "releases/**", "ohos/**"]
tags:
- "v*"
- "!*-pre"
pull_request: pull_request:
branches: ["develop", "main"] branches: [develop, main, "ohos/**"]
types: [opened, synchronize, reopened, ready_for_review]
paths:
- "Cargo.toml"
- "Cargo.lock"
- "rust-toolchain.toml"
- ".cargo/**"
- "easytier/**"
- "easytier-core/**"
- "easytier-proto/**"
- "easytier-rpc-build/**"
- "easytier-contrib/easytier-ohrs/**"
- ".github/workflows/ohos.yml"
- ".github/actions/**"
workflow_dispatch:
inputs:
publish:
description: Publish this non-main branch and dispatch downstream builds
required: false
default: false
type: boolean
permissions:
actions: write
contents: read
pull-requests: read
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
defaults: defaults:
run: run:
# necessary for windows
shell: bash shell: bash
jobs: jobs:
pre_job: pre_job:
# continue-on-error: true # Uncomment once integration is finished
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Map a step output to a job output if: github.event_name != 'pull_request' || !github.event.pull_request.draft
outputs: outputs:
# do not skip push on branch starts with releases/ should_skip: ${{ steps.skip_check.outputs.should_skip == 'true' && github.ref_type != 'tag' && github.ref_name != 'main' && !startsWith(github.ref_name, 'releases/') }}
should_skip: ${{ steps.skip_check.outputs.should_skip == 'true' && !startsWith(github.ref_name, 'releases/') }}
steps: steps:
- id: skip_check - id: skip_check
uses: fkirc/skip-duplicate-actions@v5 uses: fkirc/skip-duplicate-actions@v5
with: with:
# All of these options are optional, so you can remove them if you are happy with the defaults
concurrent_skipping: 'same_content_newer' concurrent_skipping: 'same_content_newer'
skip_after_successful_duplicate: 'true' skip_after_successful_duplicate: 'true'
cancel_others: 'true' cancel_others: 'true'
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-contrib/easytier-ohrs/**", ".github/workflows/ohos.yml", ".github/workflows/install_rust.sh"]' paths: '["Cargo.toml", "Cargo.lock", "rust-toolchain.toml", ".cargo/**", "easytier/**", "easytier-core/**", "easytier-proto/**", "easytier-contrib/easytier-ohrs/**", ".github/workflows/ohos.yml", ".github/actions/**"]'
build-ohos:
runs-on: ubuntu-latest ohos:
name: ohos
needs: pre_job needs: pre_job
if: needs.pre_job.outputs.should_skip != 'true' if: needs.pre_job.outputs.should_skip != 'true'
runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - name: Checkout
- name: Install dependencies uses: actions/checkout@v5
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
wget \
unzip \
git \
pkg-config
sudo apt-get clean
- name: Download and extract native SDK
working-directory: ../../../
run: |
echo $PWD
wget -q \
https://github.com/openharmony-rs/ohos-sdk/releases/download/v5.1.0/ohos-sdk-windows_linux-public.tar.gz.aa
wget -q \
https://github.com/openharmony-rs/ohos-sdk/releases/download/v5.1.0/ohos-sdk-windows_linux-public.tar.gz.ab
cat ohos-sdk-windows_linux-public.tar.gz.aa ohos-sdk-windows_linux-public.tar.gz.ab > sdk.tar.gz
echo "Extracting native..."
mkdir sdk
tar -xzf sdk.tar.gz ohos-sdk/linux/native-linux-x64-5.1.0.107-Release.zip
tar -xzf sdk.tar.gz ohos-sdk/linux/toolchains-linux-x64-5.1.0.107-Release.zip
unzip -qq ohos-sdk/linux/native-linux-x64-5.1.0.107-Release.zip -d sdk
unzip -qq ohos-sdk/linux/toolchains-linux-x64-5.1.0.107-Release.zip -d sdk
ls -la sdk/native/llvm/bin/
rm -rf ohos-sdk-windows_linux-public.tar.gz.aa ohos-sdk-windows_linux-public.tar.gz.ab ohos-sdk/
- 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/* $HOME/sdk/native
echo "Custom SDK files deployed to $HOME/sdk/native"
ls -a $HOME/sdk/native
- name: Setup build environment
run: |
echo "OHOS_NDK_HOME=$HOME/sdk" >> $GITHUB_ENV
echo "TARGET_ARCH=aarch64-linux-ohos" >> $GITHUB_ENV
- name: Create clang wrapper script
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'
#!/bin/sh
exec $OHOS_NDK_HOME/native/llvm/bin/clang \
-target aarch64-linux-ohos \
--sysroot=$OHOS_NDK_HOME/native/sysroot \
-D__MUSL__ \
"$@"
EOF
sudo chmod +x $OHOS_NDK_HOME/native/llvm/aarch64-unknown-linux-ohos-clang.sh
- name: Build
working-directory: ./easytier-contrib/easytier-ohrs
run: |
sudo apt-get install -y llvm clang lldb lld
sudo apt-get install -y protobuf-compiler
bash ../../.github/workflows/install_rust.sh
source env.sh
cargo install ohrs
rustup target add aarch64-unknown-linux-ohos
cargo update easytier
ohrs doctor
ohrs build --release --arch aarch
- name: Upload artifact
uses: actions/upload-artifact@v4
with: with:
name: easytier-ohos fetch-depth: 0
path: ./easytier-contrib/easytier-ohrs/dist/arm64-v8a/libeasytier_ohrs.so
- name: Set up Rust
uses: ./.github/actions/prepare-build
with:
target: aarch64-unknown-linux-ohos
gui: false
pnpm: false
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up HarmonyOS
uses: ErBWs/setup-ohos@v1
- name: Install ohrs
uses: taiki-e/install-action@v2
with:
tool: ohrs
- name: Build HAR
id: package
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
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 test --locked --manifest-path \
easytier-contrib/easytier-ohrs/Cargo.toml \
-p easytier-ohos-core -p easytier-ohos-features \
--lib -- --test-threads=1
cargo check --locked --manifest-path \
easytier-contrib/easytier-ohrs/Cargo.toml \
-p easytier-ohrs --tests
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" \
-target aarch64-linux-ohos \
--sysroot="$OHOS_NDK_HOME/native/sysroot" \
-D__MUSL__ "$@"
EOF
sudo chmod +x \
"$OHOS_NDK_HOME/native/llvm/aarch64-unknown-linux-ohos-clang.sh"
cd easytier-contrib/easytier-ohrs
source env.sh
ohrs build --release --arch aarch
ohrs artifact
mv package.har "$package_name.har"
- name: Upload HAR
uses: actions/upload-artifact@v5
with:
name: ${{ steps.package.outputs.name }}
path: easytier-contrib/easytier-ohrs/${{ steps.package.outputs.name }}.har
retention-days: 5 retention-days: 5
if-no-files-found: error if-no-files-found: error
- 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:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CODEARTS_PRIVATE_OHPM: ${{ secrets.CODEARTS_PRIVATE_OHPM }}
DOWNSTREAM_DISPATCH_TOKEN: ${{ secrets.DOWNSTREAM_DISPATCH_TOKEN }}
run: |
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
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
+6 -10
View File
@@ -6,22 +6,19 @@ on:
core_run_id: core_run_id:
description: 'The run id of EasyTier-Core Action in EasyTier repo' description: 'The run id of EasyTier-Core Action in EasyTier repo'
type: number type: number
default: 10322498549
required: true required: true
gui_run_id: gui_run_id:
description: 'The run id of EasyTier-GUI Action in EasyTier repo' description: 'The run id of EasyTier-GUI Action in EasyTier repo'
type: number type: number
default: 10322498557
required: true required: true
mobile_run_id: mobile_run_id:
description: 'The run id of EasyTier-Mobile Action in EasyTier repo' description: 'The run id of EasyTier-Mobile Action in EasyTier repo'
type: number type: number
default: 10322498555
required: true required: true
version: version:
description: 'Version for this release' description: 'Version for this release'
type: string type: string
default: 'v2.4.5' default: 'v2.7.0'
required: true required: true
make_latest: make_latest:
description: 'Mark this release as latest' description: 'Mark this release as latest'
@@ -34,19 +31,18 @@ permissions:
jobs: jobs:
release: release:
if: contains('["KKRainbow"]', github.actor)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- -
name: Checkout name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Download Core Artifact - name: Download Core Artifact
uses: dawidd6/action-download-artifact@v11 uses: dawidd6/action-download-artifact@v11
with: with:
github_token: ${{secrets.GITHUB_TOKEN}} github_token: ${{secrets.GITHUB_TOKEN}}
run_id: ${{ inputs.core_run_id }} run_id: ${{ inputs.core_run_id }}
repo: EasyTier/EasyTier repo: ${{ github.repository }}
path: release_assets path: release_assets
- name: Download GUI Artifact - name: Download GUI Artifact
@@ -54,7 +50,7 @@ jobs:
with: with:
github_token: ${{secrets.GITHUB_TOKEN}} github_token: ${{secrets.GITHUB_TOKEN}}
run_id: ${{ inputs.gui_run_id }} run_id: ${{ inputs.gui_run_id }}
repo: EasyTier/EasyTier repo: ${{ github.repository }}
path: release_assets_nozip path: release_assets_nozip
- name: Download Mobile Artifact - name: Download Mobile Artifact
@@ -62,7 +58,7 @@ jobs:
with: with:
github_token: ${{secrets.GITHUB_TOKEN}} github_token: ${{secrets.GITHUB_TOKEN}}
run_id: ${{ inputs.mobile_run_id }} run_id: ${{ inputs.mobile_run_id }}
repo: EasyTier/EasyTier repo: ${{ github.repository }}
path: release_assets_nozip path: release_assets_nozip
- name: Zip release assets - name: Zip release assets
@@ -96,4 +92,4 @@ jobs:
files: | files: |
./zipped_assets/* ./zipped_assets/*
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
tag_name: ${{ inputs.version }} tag_name: ${{ inputs.version }}
+156 -72
View File
@@ -2,12 +2,18 @@ name: EasyTier Test
on: on:
push: push:
branches: ["develop", "main"] branches: [ "develop", "main" ]
pull_request: pull_request:
branches: ["develop", "main"] branches: [ "develop", "main" ]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
# RUSTC_WRAPPER: "sccache"
# SCCACHE_GHA_ENABLED: "true"
defaults: defaults:
run: run:
@@ -16,85 +22,63 @@ defaults:
jobs: jobs:
pre_job: pre_job:
# continue-on-error: true # Uncomment once integration is finished
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Map a step output to a job output env:
RELEVANT_PATHS: >-
["Cargo.toml", "Cargo.lock", "rust-toolchain.toml", ".cargo/**",
"pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json", "easytier/**",
"easytier-core/**", "easytier-proto/**", "easytier-rpc-build/**",
"easytier-web/**", "easytier-gui/src-tauri/**",
"tauri-plugin-vpnservice/**", "easytier-contrib/**",
".github/workflows/test.yml", ".github/actions/**"]
outputs: outputs:
should_skip: ${{ steps.skip_check.outputs.should_skip }} should_skip: >-
${{
steps.skip_check.outputs.should_skip == 'true' ||
(
github.event_name == 'pull_request' &&
steps.path_filter.outputs.relevant != 'true'
)
}}
steps: steps:
- id: skip_check - id: skip_check
uses: fkirc/skip-duplicate-actions@v5 uses: fkirc/skip-duplicate-actions@v5
with: with:
# All of these options are optional, so you can remove them if you are happy with the defaults
concurrent_skipping: 'never' concurrent_skipping: 'never'
skip_after_successful_duplicate: 'true' skip_after_successful_duplicate: 'true'
paths: '["Cargo.toml", "Cargo.lock", "easytier/**", ".github/workflows/test.yml", ".github/workflows/install_gui_dep.sh", ".github/workflows/install_rust.sh"]' paths: ${{ env.RELEVANT_PATHS }}
test:
runs-on: ubuntu-22.04 - id: path_filter
if: >-
github.event_name == 'pull_request' &&
steps.skip_check.outputs.should_skip != 'true'
uses: dorny/paths-filter@v4
with:
filters: |
relevant: ${{ env.RELEVANT_PATHS }}
check:
name: Run linters & check
runs-on: ubuntu-latest
needs: pre_job needs: pre_job
if: needs.pre_job.outputs.should_skip != 'true' if: needs.pre_job.outputs.should_skip != 'true'
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v5
- name: Setup protoc - name: Prepare build environment
uses: arduino/setup-protoc@v3 uses: ./.github/actions/prepare-build
with: with:
# GitHub repo token to use to avoid rate limiter gui: true
repo-token: ${{ secrets.GITHUB_TOKEN }} pnpm: true
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup tools for test - uses: actions-rust-lang/setup-rust-toolchain@v1
run: sudo apt install bridge-utils
- name: Setup system for test
run: |
sudo modprobe br_netfilter
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
sudo ip addr add 2001:db8::2/64 dev lo
- uses: actions/setup-node@v4
with: with:
node-version: 22 components: rustfmt,clippy
target: wasm32-wasip1
rustflags: ''
- name: Install pnpm - uses: taiki-e/install-action@cargo-hack
uses: pnpm/action-setup@v4
with:
version: 10
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install frontend dependencies
run: |
pnpm -r install
pnpm -r --filter "./easytier-web/*" build
- name: Cargo cache
uses: actions/cache@v4
with:
path: |
~/.cargo
./target
key: ${{ runner.os }}-cargo-test-${{ hashFiles('**/Cargo.lock') }}
- name: Install GUI dependencies (Used by clippy)
run: |
bash ./.github/workflows/install_gui_dep.sh
bash ./.github/workflows/install_rust.sh
rustup component add rustfmt
rustup component add clippy
- name: Check formatting - name: Check formatting
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
@@ -102,14 +86,114 @@ jobs:
- name: Check Clippy - name: Check Clippy
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
# NOTE: tauri need `dist` dir in build.rs run: cargo clippy --all-targets --features full --all -- -D warnings
- name: Check features
if: ${{ !cancelled() }}
run: cargo hack check --package easytier --each-feature --exclude-features macos-ne --verbose
- name: Check WASI
if: ${{ !cancelled() }}
run: >-
cargo check --package easytier-core --lib --target wasm32-wasip1
--features management-rpc,proxy-smoltcp-stack,ring-crypto,wasi-crypto-offload
- name: Check Cargo.lock is up to date
if: ${{ !cancelled() }}
run: | run: |
mkdir -p easytier-gui/dist if ! cargo metadata --format-version 1 --locked > /dev/null; then
cargo clippy --all-targets --all-features --all -- -D warnings echo "::error::Cargo.lock is out of date. Run cargo generate-lockfile or cargo build locally, then commit Cargo.lock."
exit 1
fi
pre-test:
name: Build test
runs-on: ubuntu-latest
needs: pre_job
if: needs.pre_job.outputs.should_skip != 'true'
steps:
- uses: actions/checkout@v5
- name: Prepare build environment
uses: ./.github/actions/prepare-build
with:
gui: true
pnpm: true
token: ${{ secrets.GITHUB_TOKEN }}
- uses: Swatinem/rust-cache@v2
- uses: taiki-e/install-action@nextest
- name: Archive test
run: >-
cargo nextest archive --archive-file tests.tar.zst
--package easytier --package easytier-core --features full
- uses: actions/upload-artifact@v5
with:
name: tests
path: tests.tar.zst
retention-days: 1
test_matrix:
name: Test (${{ matrix.name }})
runs-on: ubuntu-latest
needs: [ pre_job, pre-test ]
if: needs.pre_job.outputs.should_skip != 'true'
strategy:
fail-fast: false
matrix:
include:
- name: "easytier"
opts: "-E 'not test(tests::three_node)' --test-threads 1 --no-fail-fast"
- name: "three_node"
opts: "-E 'test(tests::three_node) and not test(subnet_proxy_three_node_test)' --test-threads 1 --no-fail-fast"
- name: "three_node::subnet_proxy_three_node_test"
opts: "-E 'test(subnet_proxy_three_node_test)' --test-threads 1 --no-fail-fast"
steps:
- uses: actions/checkout@v5
- name: Setup tools for test
run: sudo apt install bridge-utils
- name: Setup upnpd for test
run: |
sudo apt-get update
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y miniupnpd miniupnpd-iptables iptables
- name: Setup system for test
run: |
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
sudo ip addr add 2001:db8::2/64 dev lo
- uses: taiki-e/install-action@nextest
- name: Download tests
uses: actions/download-artifact@v4
with:
name: tests
- name: Run tests - name: Run tests
run: | run: |
sudo prlimit --pid $$ --nofile=1048576:1048576 sudo prlimit --pid $$ --nofile=1048576:1048576
sudo -E env "PATH=$PATH" cargo test --no-default-features --features=full --verbose -- --test-threads=1 sudo -E env "PATH=$PATH" EASYTIER_LINUX_BPF_INTEGRATION=required \
sudo chown -R $USER:$USER ./target cargo nextest run --archive-file tests.tar.zst ${{ matrix.opts }}
sudo chown -R $USER:$USER ~/.cargo
test:
runs-on: ubuntu-latest
needs: [ pre_job, check, test_matrix ]
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
steps:
- name: Mark result as failed
if: contains(needs.*.result, 'failure')
run: exit 1
+7
View File
@@ -34,11 +34,18 @@ easytier-panic.log
# web # web
node_modules node_modules
easytier-web/frontend-lib/src/generated/
easytier-web/config-generator/dist/
easytier-web/config-generator/src/generated/
.vite .vite
easytier-gui/src-tauri/*.dll easytier-gui/src-tauri/*.dll
easytier-gui/src-tauri/*.sys
/easytier-contrib/easytier-ohrs/dist/ /easytier-contrib/easytier-ohrs/dist/
.direnv .direnv
.flake-profile .flake-profile
# contrib
go.sum
+105
View File
@@ -0,0 +1,105 @@
# 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.
## Credential grant
A credential grant contains the authorization constraints shared by generated,
imported, managed, and attached-peer credentials: ACL groups, relay permission,
allowed proxy CIDRs, and whether concurrent reuse is allowed. It does not own
credential identity, key material, lifetime, persistence, or runtime ownership.
Each credential intake path normalizes the grant before installing it.
## Peer Relay advertisement
A platform peer may prefer an eligible directly connected credential relay by
omitting covered credential-leaf edges from only its own advertised OSPF
connection row. Its local route calculation still uses the complete physical
adjacency so direct-destination fallback remains available. Other peers'
source-owned rows and versions are never rewritten, cached for promotion, or
otherwise changed by this projection.
Before a graceful Instance stop, the owner publishes a new-version empty
connection row while keeping its physical adjacencies available for route
synchronization. It waits for the current direct route Sessions to acknowledge
that withdrawal up to a bounded deadline, then continues shutdown. Abrupt
process loss cannot publish this withdrawal and retains the normal route
expiry behavior.
Relay eligibility comes from the transport-authenticated credential identity
and grant, not self-reported route metadata. The advertisement Module does not
support changing a credential's relay permission in place; such a permission
change is a credential revocation and new authenticated Session.
## Attached peer
An attached peer is an ordinary `PeerManagerCore` connected to another
`PeerManagerCore` through an authenticated in-process transport. Each
authenticated portal client owns one complete peer manager. The managers are
protocol peers; `attached` describes only the local transport and its trusted
ingress provenance, not a parent/child peer role.
An attached peer owns one complete IPv4 CIDR (for example `10.144.0.5/16`).
Its address and advertised network are independent of the network manager's
own static or DHCP address. A VPN portal derives the attached peer route and
the external client's allowed network from that single CIDR; it does not infer
either value from the portal-hosting instance.
An external portal client uses that same IPv4 address on its native tunnel
interface. The portal validates the source address and forwards IPv4 packets
unchanged between the native tunnel and the attached peer; it does not assign
a second tunnel-only address or perform address translation.
Each manager owns its ACL execution state, route service, RPC endpoint, secure
sessions, packet processing, and lifecycle. Portal code supplies raw packets
and peer configuration but does not build, reload, or coordinate ACL filters.
When the network manager uses Secure Mode, an attached peer authenticates as a
credential peer. Its portal-owned, in-memory credential grant carries ACL
groups and is revoked with the attached runtime; the peer never receives the
network secret or ACL group secrets. A non-Secure-Mode network retains the
legacy admin-attached identity for compatibility. A credential peer cannot host
a portal because it cannot issue credential grants. Each live portal Session
owns a fresh attached-peer identity, while the external client key remains
stable across Sessions; a replacement Session must never reuse the previous
non-reusable credential identity.
## 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.
## Web compatibility Host
The Web compatibility Host runs the portable EasyTier guest in JavaScript
runtimes that provide WebAssembly JSPI. Its shared runtime Module owns guest
lifecycle, Host capability operations, data-plane resources, and WebSocket
message handling. Browser and Cloudflare Adapters own only the platform-specific
way that WebSockets are dialed or accepted and the matching guest artifact.
The Browser Adapter is an outbound-only EasyTier instance with a smoltcp TCP
data plane. The Cloudflare Adapter is an inbound-only relay hosted by one named
Durable Object. Their public configuration exposes only capabilities each Host
can execute; guest ABI details and serialized TOML remain internal.
+15 -4
View File
@@ -26,7 +26,7 @@ Thank you for your interest in contributing to EasyTier! This document provides
#### Required Tools #### Required Tools
- Node.js v21 or higher - Node.js v21 or higher
- pnpm v9 or higher - pnpm v9 or higher
- Rust toolchain (version 1.89) - Rust toolchain (version 1.95)
- LLVM and Clang - LLVM and Clang
- Protoc (Protocol Buffers compiler) - Protoc (Protocol Buffers compiler)
@@ -79,8 +79,8 @@ sudo apt install -y bridge-utils
2. Install dependencies: 2. Install dependencies:
```bash ```bash
# Install Rust toolchain # Install Rust toolchain
rustup install 1.89 rustup install 1.95
rustup default 1.89 rustup default 1.95
# Install project dependencies # Install project dependencies
pnpm -r install pnpm -r install
@@ -113,6 +113,17 @@ cargo build --release --target x86_64-pc-windows-msvc # Windows x86_64
Build artifacts: `target/[target-triple]/release/` 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 ### Building GUI
```bash ```bash
@@ -222,4 +233,4 @@ Feel free to:
- Join our community discussions - Join our community discussions
- Reach out to maintainers - Reach out to maintainers
Thank you for contributing to EasyTier! Thank you for contributing to EasyTier!
+3 -3
View File
@@ -34,7 +34,7 @@
#### 必需工具 #### 必需工具
- Node.js v21 或更高版本 - Node.js v21 或更高版本
- pnpm v9 或更高版本 - pnpm v9 或更高版本
- Rust 工具链(版本 1.89 - Rust 工具链(版本 1.95
- LLVM 和 Clang - LLVM 和 Clang
- ProtocProtocol Buffers 编译器) - ProtocProtocol Buffers 编译器)
@@ -87,8 +87,8 @@ sudo apt install -y bridge-utils
2. 安装依赖: 2. 安装依赖:
```bash ```bash
# 安装 Rust 工具链 # 安装 Rust 工具链
rustup install 1.89 rustup install 1.95
rustup default 1.89 rustup default 1.95
# 安装项目依赖 # 安装项目依赖
pnpm -r install pnpm -r install
Generated
+4473 -3594
View File
File diff suppressed because it is too large Load Diff
+83 -1
View File
@@ -1,19 +1,96 @@
[workspace] [workspace]
resolver = "2" resolver = "2"
members = [ members = [
"easytier-core",
"easytier-proto",
"easytier", "easytier",
"easytier-gui/src-tauri", "easytier-gui/src-tauri",
"easytier-rpc-build",
"easytier-web", "easytier-web",
"easytier-contrib/easytier-mini",
"easytier-contrib/easytier-ffi", "easytier-contrib/easytier-ffi",
"easytier-contrib/easytier-uptime", "easytier-contrib/easytier-uptime",
"easytier-contrib/easytier-android-jni", "easytier-contrib/easytier-android-jni",
"easytier-contrib/easytier-ios",
] ]
default-members = ["easytier", "easytier-web"] default-members = ["easytier", "easytier-web"]
exclude = [ exclude = [
"easytier-contrib/easytier-ohrs", # it needs ohrs sdk "easytier-contrib/easytier-ohrs", # it needs ohrs sdk
] ]
[workspace.package]
edition = "2024"
rust-version = "1.95"
[workspace.dependencies]
# Shared versions and sources; members select features and optional dependencies.
anyhow = "1.0"
arc-swap = "1.9"
async-trait = "0.1.92"
atomic-shim = "0.2.0"
auto_impl = "1.3.0"
base64 = "0.23"
bon = "3.10.1"
bytecodec = "0.5.0"
bytes = "1.12.1"
chrono = "0.4.45"
cidr = "0.3.2"
clap = "4.6.7"
crossbeam = "0.8.5"
dashmap = "6.2.1"
easytier = { version = "2.7.0", path = "easytier", default-features = false }
easytier-core = { version = "2.7.0", path = "easytier-core", default-features = false }
easytier-ffi = { path = "easytier-contrib/easytier-ffi", default-features = false }
easytier-proto = { version = "2.7.0", path = "easytier-proto", default-features = false }
futures = "0.3"
guarden = "0.3"
hmac = "0.13.0"
http-body-util = "0.1"
hyper = { version = "1", default-features = false }
hyper-util = { version = "0.1", default-features = false }
indoc = "2.0.7"
log = "0.4"
mimalloc = "*"
once_cell = "1.21"
parking_lot = "0.12.5"
percent-encoding = "2.3.2"
pin-project-lite = "0.2.17"
prost = "0.14.4"
prost-types = "0.14.4"
quanta = "0.12"
# rand 0.10 / x25519-dalek 3 require a coordinated RNG trait migration.
rand = "0.8.8"
reqwest = { version = "0.13.5", default-features = false }
rust-i18n = "4"
rustls = { version = "0.23.45", default-features = false }
# tower-sessions-sqlx-store 0.15 requires SQLx 0.8; SeaORM 2 requires SQLx 0.9.
sea-orm = "1.1.20"
sea-orm-migration = "1.1.20"
serde = "1.0.229"
serde_json = "1.0"
sha2 = "0.11.0"
smoltcp = { version = "0.14.0", default-features = false }
sqlx = "0.8.6"
strum = "0.28.0"
stun_codec = "0.4.0"
sys-locale = "0.3"
tauri = "2.11.5"
tempfile = "3.27.0"
thiserror = "2.0"
thunk-rs = { git = "https://github.com/EasyTier/thunk.git", default-features = false }
tokio = { version = "1.53", default-features = false }
tokio-rustls = { version = "0.26", default-features = false }
tokio-util = "0.7.19"
toml = "1.1.6"
tower-http = "0.7"
tracing = "0.1"
tracing-subscriber = { version = "0.3", default-features = false }
url = "2.5"
uuid = "1.26.1"
winapi = "0.3.9"
x25519-dalek = "2.0.1"
# zerocopy 0.8 requires migrating the packet layout and casting APIs.
zerocopy = "0.7.35"
[profile.dev] [profile.dev]
panic = "unwind" panic = "unwind"
debug = 2 debug = 2
@@ -24,3 +101,8 @@ lto = true
codegen-units = 1 codegen-units = 1
opt-level = 3 opt-level = 3
strip = true strip = true
[profile.mini]
inherits = "release"
opt-level = "z"
strip = "symbols"
+47 -36
View File
@@ -48,40 +48,43 @@
Choose the installation method that best suits your needs: Choose the installation method that best suits your needs:
Linux (Recommended):
```bash ```bash
# 1. Download pre-built binary (Recommended, All platforms supported) curl -fsSL "https://github.com/EasyTier/EasyTier/blob/main/script/install.sh?raw=true" | sudo bash -s install
# Visit https://github.com/EasyTier/EasyTier/releases ```
# 2. Install via cargo (Latest development version) Homebrew (MacOS/Linux):
cargo install --git https://github.com/EasyTier/EasyTier.git easytier ```bash
# 3. Install via Docker
# See https://easytier.cn/en/guide/installation.html#installation-methods
# 4. Linux Quick Install
wget -O- https://raw.githubusercontent.com/EasyTier/EasyTier/main/script/install.sh | sudo bash -s install
# 5. MacOS via Homebrew
brew tap brewforge/chinese brew tap brewforge/chinese
brew install --cask easytier-gui brew install --cask easytier-gui
# 6. OpenWrt Luci Web UI
# Visit https://github.com/EasyTier/luci-app-easytier
# 7. (Optional) Install shell completions:
easytier-core --gen-autocomplete fish > ~/.config/fish/completions/easytier-core.fish
easytier-cli gen-autocomplete fish > ~/.config/fish/completions/easytier-cli.fish
``` ```
Windows (Recommended, run with administrator privileges):
```powershell
irm "https://github.com/EasyTier/EasyTier/blob/main/script/install.ps1?raw=true" | iex
```
Install via cargo (Latest development version):
```bash
cargo install --git https://github.com/EasyTier/EasyTier.git easytier
```
[Install pre-built binary](https://github.com/EasyTier/EasyTier/releases) (Recommended, All platforms supported)
[Install via Docker](https://easytier.cn/en/guide/installation.html#installation-methods)
[Install OpenWrt ipk package](https://github.com/EasyTier/luci-app-easytier)
Additional steps:
[One-Click Register Service](https://easytier.cn/en/guide/network/oneclick-install-as-service.html) (Automatically start when the system boots and run in the background)
### 🚀 Basic Usage ### 🚀 Basic Usage
#### Quick Networking with Shared Nodes #### Quick Networking with Shared Nodes
EasyTier supports quick networking using shared public nodes. When you don't have a public IP, you can use the free shared nodes provided by the EasyTier community. Nodes will automatically attempt NAT traversal and establish P2P connections. When P2P fails, data will be relayed through shared nodes. EasyTier supports quick networking using shared public nodes. When you don't have a public IP, you can use the free shared nodes provided by the EasyTier community. Nodes will automatically attempt NAT traversal and establish P2P connections. When P2P fails, data will be relayed through shared nodes.
The currently deployed shared public node is `tcp://public.easytier.cn:11010`.
When using shared nodes, each node entering the network needs to provide the same `--network-name` and `--network-secret` parameters as the unique identifier of the network. When using shared nodes, each node entering the network needs to provide the same `--network-name` and `--network-secret` parameters as the unique identifier of the network.
Taking two nodes as an example (Please use more complex network name to avoid conflicts): Taking two nodes as an example (Please use more complex network name to avoid conflicts):
@@ -90,14 +93,14 @@ Taking two nodes as an example (Please use more complex network name to avoid co
```bash ```bash
# Run with administrator privileges # Run with administrator privileges
sudo easytier-core -d --network-name abc --network-secret abc -p tcp://public.easytier.cn:11010 sudo easytier-core -d --network-name abc --network-secret abc -p tcp://<SharedNodeIP>:11010
``` ```
2. Run on Node B: 2. Run on Node B:
```bash ```bash
# Run with administrator privileges # Run with administrator privileges
sudo easytier-core -d --network-name abc --network-secret abc -p tcp://public.easytier.cn:11010 sudo easytier-core -d --network-name abc --network-secret abc -p tcp://<SharedNodeIP>:11010
``` ```
After successful execution, you can check the network status using `easytier-cli`: After successful execution, you can check the network status using `easytier-cli`:
@@ -105,9 +108,9 @@ After successful execution, you can check the network status using `easytier-cli
```text ```text
| ipv4 | hostname | cost | lat_ms | loss_rate | rx_bytes | tx_bytes | tunnel_proto | nat_type | id | version | | ipv4 | hostname | cost | lat_ms | loss_rate | rx_bytes | tx_bytes | tunnel_proto | nat_type | id | version |
| ------------ | -------------- | ----- | ------ | --------- | -------- | -------- | ------------ | -------- | ---------- | --------------- | | ------------ | -------------- | ----- | ------ | --------- | -------- | -------- | ------------ | -------- | ---------- | --------------- |
| 10.126.126.1 | abc-1 | Local | * | * | * | * | udp | FullCone | 439804259 | 2.4.5-70e69a38~ | | 10.126.126.1 | abc-1 | Local | * | * | * | * | udp | FullCone | 439804259 | 2.6.2-70e69a38~ |
| 10.126.126.2 | abc-2 | p2p | 3.452 | 0 | 17.33 kB | 20.42 kB | udp | FullCone | 390879727 | 2.4.5-70e69a38~ | | 10.126.126.2 | abc-2 | p2p | 3.452 | 0 | 17.33 kB | 20.42 kB | udp | FullCone | 390879727 | 2.6.2-70e69a38~ |
| | PublicServer_a | p2p | 27.796 | 0.000 | 50.01 kB | 67.46 kB | tcp | Unknown | 3771642457 | 2.4.5-70e69a38~ | | | PublicServer_a | p2p | 27.796 | 0.000 | 50.01 kB | 67.46 kB | tcp | Unknown | 3771642457 | 2.6.2-70e69a38~ |
``` ```
You can test connectivity between nodes: You can test connectivity between nodes:
@@ -124,7 +127,7 @@ To improve availability, you can connect to multiple shared nodes simultaneously
```bash ```bash
# Connect to multiple shared nodes # Connect to multiple shared nodes
sudo easytier-core -d --network-name abc --network-secret abc -p tcp://public.easytier.cn:11010 -p udp://public.easytier.cn:11010 sudo easytier-core -d --network-name abc --network-secret abc -p tcp://<SharedNodeIP1>:11010 -p udp://<SharedNodeIP2>:11010
``` ```
Once your network is set up successfully, you can easily configure it to start automatically on system boot. Refer to the [One-Click Register Service guide](https://easytier.cn/en/guide/network/oneclick-install-as-service.html) for step-by-step instructions on registering EasyTier as a system service. Once your network is set up successfully, you can easily configure it to start automatically on system boot. Refer to the [One-Click Register Service guide](https://easytier.cn/en/guide/network/oneclick-install-as-service.html) for step-by-step instructions on registering EasyTier as a system service.
@@ -249,8 +252,12 @@ ios <-.-> nodea <--> nodeb <-.-> id1
1. Start EasyTier with WireGuard portal enabled: 1. Start EasyTier with WireGuard portal enabled:
```bash ```bash
# Listen on 0.0.0.0:11013 and use 10.14.14.0/24 subnet for WireGuard clients # Register one WireGuard client as virtual peer 10.144.144.3
sudo easytier-core -i 10.144.144.1 --vpn-portal wg://0.0.0.0:11013/10.14.14.0/24 sudo easytier-core -i 10.144.144.1 \
--network-secret portal-secret \
--vpn-portal wg://0.0.0.0:11013 \
--vpn-portal-private-key "$(wg genkey)" \
--vpn-portal-client phone=10.144.144.3
``` ```
2. Get WireGuard client configuration: 2. Get WireGuard client configuration:
@@ -260,10 +267,10 @@ sudo easytier-core -i 10.144.144.1 --vpn-portal wg://0.0.0.0:11013/10.14.14.0/24
easytier-cli vpn-portal easytier-cli vpn-portal
``` ```
3. In the output configuration: 3. In the output configuration, replace a wildcard `Peer.Endpoint` with the
- Set `Interface.Address` to an available IP from the WireGuard subnet public IP/domain of your EasyTier node, then import it. `Interface.Address`
- Set `Peer.Endpoint` to the public IP/domain of your EasyTier node is local to that WireGuard client and may be changed to any IPv4 address;
- Import the modified configuration into your WireGuard client EasyTier translates it to the registered virtual-peer address.
#### Self-Hosted Public Shared Node #### Self-Hosted Public Shared Node
@@ -280,8 +287,6 @@ sudo easytier-core --network-name mysharednode --network-secret mysharednode
- [ZeroTier](https://www.zerotier.com/): A global virtual network for connecting devices. - [ZeroTier](https://www.zerotier.com/): A global virtual network for connecting devices.
- [TailScale](https://tailscale.com/): A VPN solution aimed at simplifying network configuration. - [TailScale](https://tailscale.com/): A VPN solution aimed at simplifying network configuration.
- [vpncloud](https://github.com/dswd/vpncloud): A P2P Mesh VPN
- [Candy](https://github.com/lanthora/candy): A reliable, low-latency, and anti-censorship virtual private network
### Contact Us ### Contact Us
@@ -295,6 +300,12 @@ sudo easytier-core --network-name mysharednode --network-secret mysharednode
EasyTier is released under the [LGPL-3.0](https://github.com/EasyTier/EasyTier/blob/main/LICENSE). EasyTier is released under the [LGPL-3.0](https://github.com/EasyTier/EasyTier/blob/main/LICENSE).
## Responsible Use
Use EasyTier only for lawful purposes and in compliance with applicable laws
and regulations. You are responsible for ensuring that you are authorized to
connect to and administer the networks and devices involved.
## Sponsor ## Sponsor
CDN acceleration and security protection for this project are sponsored by Tencent EdgeOne. CDN acceleration and security protection for this project are sponsored by Tencent EdgeOne.
+45 -38
View File
@@ -48,40 +48,42 @@
选择最适合您需求的安装方式: 选择最适合您需求的安装方式:
Linux(推荐):
```bash ```bash
# 1. 下载预编译二进制文件(推荐,支持所有平台) curl -fsSL "https://github.com/EasyTier/EasyTier/blob/main/script/install.sh?raw=true" | sudo bash -s install
# 访问 https://github.com/EasyTier/EasyTier/releases ```
# 2. 通过 cargo 安装(最新开发版本) HomebrewMacOS/Linux):
cargo install --git https://github.com/EasyTier/EasyTier.git easytier ```bash
# 3. 通过 Docker 安装
# 参见 https://easytier.cn/guide/installation.html#%E5%AE%89%E8%A3%85%E6%96%B9%E5%BC%8F
# 4. Linux 快速安装
wget -O- https://raw.githubusercontent.com/EasyTier/EasyTier/main/script/install.sh | sudo bash -s install
# 5. MacOS 通过 Homebrew 安装
brew tap brewforge/chinese brew tap brewforge/chinese
brew install --cask easytier-gui brew install --cask easytier-gui
# 6. OpenWrt Luci Web 界面
# 访问 https://github.com/EasyTier/luci-app-easytier
# 7.(可选)安装 Shell 补全功能:
# Fish 补全
easytier-core --gen-autocomplete fish > ~/.config/fish/completions/easytier-core.fish
easytier-cli gen-autocomplete fish > ~/.config/fish/completions/easytier-cli.fish
``` ```
Windows(推荐,请以管理员权限运行):
```powershell
irm "https://github.com/EasyTier/EasyTier/blob/main/script/install.ps1?raw=true" | iex
```
通过 cargo 安装(最新开发版本):
```bash
cargo install --git https://github.com/EasyTier/EasyTier.git easytier
```
[下载预编译文件](https://github.com/EasyTier/EasyTier/releases)(推荐,支持所有平台)
[通过 Docker 安装](https://easytier.cn/guide/installation.html#%E5%AE%89%E8%A3%85%E6%96%B9%E5%BC%8F)
[安装 OpenWrt ipk 软件包](https://github.com/EasyTier/luci-app-easytier)
附加步骤:
[一键注册系统服务](https://easytier.cn/guide/network/oneclick-install-as-service.html)(系统启动时自动后台运行)
### 🚀 基本用法 ### 🚀 基本用法
#### 使用共享节点快速组网 #### 使用共享节点快速组网
EasyTier 支持使用共享公共节点快速组网。当您没有公网 IP 时,可以使用 EasyTier 社区提供的免费共享节点。节点会自动尝试 NAT 穿透并建立 P2P 连接。当 P2P 失败时,数据将通过共享节点中继。 EasyTier 支持使用共享节点快速组网。当您没有公网 IP 时,可以使用公共共享节点。节点会自动尝试 NAT 穿透并建立 P2P 连接。当 P2P 失败时,数据将通过共享节点中继。
当前部署的共享公共节点是 `tcp://public.easytier.cn:11010`
使用共享节点时,每个进入网络的节点需要提供相同的 `--network-name``--network-secret` 参数作为网络的唯一标识符。 使用共享节点时,每个进入网络的节点需要提供相同的 `--network-name``--network-secret` 参数作为网络的唯一标识符。
@@ -91,14 +93,14 @@ EasyTier 支持使用共享公共节点快速组网。当您没有公网 IP 时
```bash ```bash
# 以管理员权限运行 # 以管理员权限运行
sudo easytier-core -d --network-name abc --network-secret abc -p tcp://public.easytier.cn:11010 sudo easytier-core -d --network-name abc --network-secret abc -p tcp://<共享节点IP>:11010
``` ```
2. 在节点 B 上运行: 2. 在节点 B 上运行:
```bash ```bash
# 以管理员权限运行 # 以管理员权限运行
sudo easytier-core -d --network-name abc --network-secret abc -p tcp://public.easytier.cn:11010 sudo easytier-core -d --network-name abc --network-secret abc -p tcp://<共享节点IP>:11010
``` ```
执行成功后,可以使用 `easytier-cli` 检查网络状态: 执行成功后,可以使用 `easytier-cli` 检查网络状态:
@@ -106,9 +108,9 @@ sudo easytier-core -d --network-name abc --network-secret abc -p tcp://public.ea
```text ```text
| ipv4 | hostname | cost | lat_ms | loss_rate | rx_bytes | tx_bytes | tunnel_proto | nat_type | id | version | | ipv4 | hostname | cost | lat_ms | loss_rate | rx_bytes | tx_bytes | tunnel_proto | nat_type | id | version |
| ------------ | -------------- | ----- | ------ | --------- | -------- | -------- | ------------ | -------- | ---------- | --------------- | | ------------ | -------------- | ----- | ------ | --------- | -------- | -------- | ------------ | -------- | ---------- | --------------- |
| 10.126.126.1 | abc-1 | Local | * | * | * | * | udp | FullCone | 439804259 | 2.4.5-70e69a38~ | | 10.126.126.1 | abc-1 | Local | * | * | * | * | udp | FullCone | 439804259 | 2.6.2-70e69a38~ |
| 10.126.126.2 | abc-2 | p2p | 3.452 | 0 | 17.33 kB | 20.42 kB | udp | FullCone | 390879727 | 2.4.5-70e69a38~ | | 10.126.126.2 | abc-2 | p2p | 3.452 | 0 | 17.33 kB | 20.42 kB | udp | FullCone | 390879727 | 2.6.2-70e69a38~ |
| | PublicServer_a | p2p | 27.796 | 0.000 | 50.01 kB | 67.46 kB | tcp | Unknown | 3771642457 | 2.4.5-70e69a38~ | | | PublicServer_a | p2p | 27.796 | 0.000 | 50.01 kB | 67.46 kB | tcp | Unknown | 3771642457 | 2.6.2-70e69a38~ |
``` ```
您可以测试节点之间的连通性: 您可以测试节点之间的连通性:
@@ -125,7 +127,7 @@ ping 10.126.126.2
```bash ```bash
# 连接多个共享节点 # 连接多个共享节点
sudo easytier-core -d --network-name abc --network-secret abc -p tcp://public.easytier.cn:11010 -p udp://public.easytier.cn:11010 sudo easytier-core -d --network-name abc --network-secret abc -p tcp://<公共节点IP>:11010 -p udp://<公共节点IP>:11010
``` ```
#### 去中心化组网 #### 去中心化组网
@@ -248,8 +250,12 @@ ios <-.-> nodea <--> nodeb <-.-> id1
1. 启动启用 WireGuard 门户的 EasyTier 1. 启动启用 WireGuard 门户的 EasyTier
```bash ```bash
# 在 0.0.0.0:11013 上监听,并使用 10.14.14.0/24 子网作为 WireGuard 客户端 # 将一个 WireGuard 客户端注册为虚拟 peer 10.144.144.3
sudo easytier-core -i 10.144.144.1 --vpn-portal wg://0.0.0.0:11013/10.14.14.0/24 sudo easytier-core -i 10.144.144.1 \
--network-secret portal-secret \
--vpn-portal wg://0.0.0.0:11013 \
--vpn-portal-private-key "$(wg genkey)" \
--vpn-portal-client phone=10.144.144.3
``` ```
2. 获取 WireGuard 客户端配置: 2. 获取 WireGuard 客户端配置:
@@ -259,10 +265,9 @@ sudo easytier-core -i 10.144.144.1 --vpn-portal wg://0.0.0.0:11013/10.14.14.0/24
easytier-cli vpn-portal easytier-cli vpn-portal
``` ```
3. 输出配置中 3. 如果输出配置中`Peer.Endpoint` 是通配地址,将其替换为 EasyTier
-`Interface.Address` 设置为 WireGuard 子网中的可用 IP 节点的公网 IP/域名后即可导入。`Interface.Address` 只是客户端本地地址,
-`Peer.Endpoint` 设置为您的 EasyTier 节点的公网 IP/域名 可以改为任意 IPv4 地址;EasyTier 会把它转换成已注册的虚拟 peer 地址。
- 将修改后的配置导入到您的 WireGuard 客户端
#### 自建公共共享节点 #### 自建公共共享节点
@@ -281,8 +286,6 @@ sudo easytier-core --network-name mysharednode --network-secret mysharednode
- [ZeroTier](https://www.zerotier.com/):用于连接设备的全球虚拟网络。 - [ZeroTier](https://www.zerotier.com/):用于连接设备的全球虚拟网络。
- [TailScale](https://tailscale.com/):旨在简化网络配置的 VPN 解决方案。 - [TailScale](https://tailscale.com/):旨在简化网络配置的 VPN 解决方案。
- [vpncloud](https://github.com/dswd/vpncloud):一个 P2P 网状 VPN
- [Candy](https://github.com/lanthora/candy):一个可靠、低延迟、反审查的虚拟专用网络
### 联系我们 ### 联系我们
@@ -296,6 +299,10 @@ sudo easytier-core --network-name mysharednode --network-secret mysharednode
EasyTier 在 [LGPL-3.0](https://github.com/EasyTier/EasyTier/blob/main/LICENSE) 许可下发布。 EasyTier 在 [LGPL-3.0](https://github.com/EasyTier/EasyTier/blob/main/LICENSE) 许可下发布。
## 使用规范
请仅将 EasyTier 用于合法用途,并遵守适用的法律法规。使用者有责任确保其已获授权连接和管理相关网络与设备。
## 赞助 ## 赞助
本项目的 CDN 加速和安全防护由腾讯云 EdgeOne 赞助。 本项目的 CDN 加速和安全防护由腾讯云 EdgeOne 赞助。
+549
View File
@@ -0,0 +1,549 @@
# 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;
- host-owned, message-preserving tunnel endpoints.
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.
For host-owned tunnels, ownership crosses the guest ABI only after a bounded
Host listener queue accepts the tunnel. The queue is registered through
`CoreHostAdapters` and consumed by the normal `CoreListenerRuntime`, so the
tunnel still reaches `PeerAcceptedTunnelHandler` and cannot bypass peer
handshake, admission, events, or routing policy. Each Host receive produces
one complete `DummyTunnel` payload; no stream framing is added. The Host owns
transport-specific message validation and maps a clean close to tunnel EOF.
Hosts without outbound sockets select `CoreConnectivityMode::InboundOnly` for
one instance. That startup plan retains listeners and the peer/router while
omitting STUN, outbound connectors, and hole punching. Cargo features only
compile Host Adapters; they do not change `CoreInstance` fields, lifecycle, or
management semantics.
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.
Each VPN portal client is normalized to one attached-peer IPv4 CIDR. The
portable gateway owns that client address and prefix; the hosting network
manager's DHCP or static address is not a source of portal client routing
facts.
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
@@ -0,0 +1,584 @@
# EasyTier Web Managed Config Incremental Sync Plan
## Status
- 状态:Implemented(核心协议、持久化与 Session 增量收敛)
- 实施范围:EasyTier Web 的 HTTP 接收、校验、SQLite 持久化和 Session 运行态收敛
- 上游依赖:后续由 Console 计算并发送 Patch
- 兼容要求:保留现有 Full PUT
本文记录当前接收端方案。Session 合并已持久化 Patch 的 touched instance IDs
并在运行态收敛时读取这些实例的最新持久化状态。重启、通知丢失或无法安全判断
实例 ownership 时沿用 Full reconcile。
## 1. 背景与结论
当前 `/validate-token` webhook 已经只交换 token、机器信息和 revision,不再
携带完整 managed config 集合。剩余的大集合位于独立的配置发布路径:
```text
PUT /api/internal/users/:user-id/machines/:machine-id/networks
```
Console 每次发布都会向该路径发送完整 Exact Set。实例很多时,请求体、JSON
解析、现有配置扫描和逐条 SQLite 写入都随实例总数增长。
第一阶段采用以下方案:
1. 保留 PUT,作为完整发布、首次同步和冲突恢复路径。
2. 在同一路径增加 PATCH;普通变更只发送完整的单实例 upsert 和删除 ID。
3. PATCH 使用 `expected_config_revision` 做 compare-and-swapCAS)。
4. Full/Patch 的配置变更与 revision 更新在一个 SQLite transaction 中提交。
5. Patch 只查询和写入 touched instances,不扫描完整 Target。
6. 写入成功后通知 Session 本次 expected、target 和 transaction 实际 touched
instance IDs。
7. Session 只合并 revision 连续的 touched IDs,并以 SQLite 当前状态为准增量
收敛;可信 runtime base、通知链或 persisted target 无法证明连续时回退 Full。
普通变更的接收端成本由:
```text
O(total instances)
```
降为:
```text
wire / JSON / persistence transaction / runtime config apply = O(changed instances)
```
冷启动或 revision 冲突仍需要 `O(total)` 的 Full。这是没有可用基线时传递完整
目标状态所必需的成本;如果 Full 超过安全的单请求上限,需另行设计 staged
snapshot,而不是直接分页写入 live rows。
## 2. 目标与非目标
### 2.1 目标
1. 普通新增、更新和删除只传输、解析、查询并写入变化实例。
2. Config rows 与 persisted revision 原子提交。
3. Patch 可安全重试,并能确定性处理并发或乱序请求。
4. 保持 user-owned 与 web-owned 配置的 ownership 规则。
5. 保持 Full Exact Set 的删除和显式空集合语义。
6. 为 Full 和 Patch 设置显式且可测试的容量限制。
7. 先部署接收端,再允许 Console 使用 Patch。
### 2.2 非目标
1. 修改 `/validate-token` request/response。
2. 在本阶段实现 Console 的 diff/cache 逻辑。
3. 优化 Core heartbeat 中的完整运行实例上报。
4. 实现 Full 分页、上传会话或持久化 delivery FSM。
5. 让冷启动 Full 的成本低于 `O(total)`
## 3. 必须保持的语义
### 3.1 Full Exact Set
Full 表示一个 `(user_id, machine_id)` 下全部期望的 web-owned configs
- 请求中存在的实例应被创建或更新;
- 已存在但请求中缺失的 web-owned 实例应被删除;
- 空集合应删除该 Target 下全部 web-owned 实例;
- user-owned 实例不能被覆盖或删除。
### 3.2 Patch
Patch 只描述从一个已知 revision 到另一个 revision 的变化:
- `upserts`:新增或变化实例的完整 config;
- `delete_instance_ids`:从目标集合中删除的实例 ID
- `expected_config_revision`receiver 必须已经处于的 base revision
- `config_revision`:提交完成后的 target revision。
Patch 不是独立的完整目标。当前 revision 与 expected revision 不一致时,必须
返回冲突且不做任何写入。
### 3.3 Revision invariants
1. 一个 persisted revision 只对应与其一起提交的 web-owned projection。
2. Config mutation 和 revision advancement 必须位于同一 transaction。
3. Patch 只能应用在完全匹配的 expected revision 上。
4. 当前 revision 已等于 target revision 时,返回幂等成功且不重复写入。
5. Publisher 不得为不同目标状态复用同一个 target revision。
6. 任何其他写路径只要改变 web-owned row,就必须在同一 transaction 中清除
managed revision;否则未来 Patch 会基于错误的 base。
7. Persisted revision 与 Session applied revision 保持为两个不同事实。HTTP
成功只代表本地持久化完成,不代表 Core 已经应用。
## 4. HTTP contract
### 4.1 保留 Full PUT
路径不变:
```text
PUT /api/internal/users/:user-id/machines/:machine-id/networks
```
现有 JSON shape 保持兼容:
```json
{
"managed_network_configs": [
{
"instance_id": "11111111-1111-1111-1111-111111111111",
"network_config": {}
}
],
"config_revision": "target-revision",
"expected_config_revision": "base-revision"
}
```
`expected_config_revision` 保持当前含义:
- 字段缺失:兼容旧调用者,不检查 base;
- 空字符串:要求当前 persisted revision 不存在;
- 非空字符串:要求当前 revision 与该值相等。
新 Console 必须发送 expected revision。省略 expected 的形式只用于旧版本兼容
和明确的运维修复。
`config_revision` 的处理:
- 非空:配置与 target revision 原子提交;
- 缺失:保留旧 Full 请求兼容,但清除已有 managed revision,因此该结果不能
作为后续 Patch base
- 空字符串:拒绝为 400。
Revisioned Full 遇到 user-owned instance ID 冲突时整体失败。Legacy
unrevisioned Full 保持当前兼容行为:跳过 user-owned row,且绝不覆盖它。
### 4.2 新增 Patch
同一资源增加:
```text
PATCH /api/internal/users/:user-id/machines/:machine-id/networks
```
请求格式:
```json
{
"upserts": [
{
"instance_id": "11111111-1111-1111-1111-111111111111",
"network_config": {}
}
],
"delete_instance_ids": [
"22222222-2222-2222-2222-222222222222"
],
"config_revision": "target-revision",
"expected_config_revision": "base-revision"
}
```
Patch contract
1. 两个 revision 字段均必填、非空且不能相同。
2. `upserts` 中的 instance ID 不得重复。
3. `delete_instance_ids` 中的 ID 不得重复。
4. 同一个 ID 不得同时出现在 upsert 和 delete 中。
5. 每个 upsert 必须携带该实例的完整 `NetworkConfig`,不支持字段级 JSON
Patch。
6. `network_config` 内部的 instance ID 不受信任,receiver 使用 envelope 中的
`instance_id` 进行归一化。
7. 删除不存在的 ID 是幂等 no-op。
8. Upsert 或 delete 碰到 user-owned row 时,整个 Patch 返回冲突且不写入。
9. 不允许从“receiver revision 不存在”的未知状态直接 Patch;使用 Full 建立
Exact Set 和首个 revision。
10. 空 Patch 不能把 revision 改成另一个值;这通常表示 publisher revision
计算错误,因此返回 400。
### 4.3 HTTP outcomes
| 条件 | HTTP | 语义 |
| --- | ---: | --- |
| Full/Patch 新提交成功 | 204 | Config 和 revision 已持久化 |
| Target revision 已经存在 | 204 | 幂等成功,无 row mutation |
| Expected revision 不匹配 | 409 | 零写入,调用者重新观察或发送 Full |
| User-owned ownership 冲突 | 409 | 零写入,不能自动覆盖 |
| 非法 ID、重复、交集或非法 config | 400 | 调用 contract 错误 |
| 请求超过 byte limit | 413 | 未进入 reconciliation |
| 条目数或单 config 超过限制 | 422 | 超出接收端容量 contract |
| SQLite 错误 | 500 | Transaction rollback |
409 返回机器可读字段:revision 冲突为
`code=managed_config_revision_conflict` 并在已知时带
`current_config_revision`ownership 冲突为
`code=managed_config_ownership_conflict`。响应不得返回配置内容。日志不得记录
token、secret 或完整 config JSON。
## 5. Receiver architecture
### 5.1 Module responsibilities
| Module | 本阶段职责 |
| --- | --- |
| Internal HTTP Adapter | 内部鉴权、body/count limit、DTO 解析、HTTP 状态映射 |
| `ClientManager` | 解析 Target,调用 managed-config Interface,成功后通知 Session |
| `client_manager::managed_config` | Full/Patch 规则、归一化、typed outcome |
| `Db` Adapter | CAS、ownership fence、批量 mutation、revision transaction |
| Session runtime reconciliation | 校验 applied/base/target fence,增量收敛 touched instances;断链时 Full |
HTTP Adapter 不实现 ownership、diff 或 transaction 逻辑。PUT 和 PATCH 共用
managed-config Module,避免两套规则逐渐分叉。
### 5.2 Internal Interface
Module 接收两种 intent
```text
Full {
desired_configs,
target_revision: Option<Revision>,
expected_revision: Any | Exact(Option<Revision>)
}
Patch {
upserts,
delete_instance_ids,
target_revision: Revision,
expected_revision: Revision
}
```
返回 typed outcome
```text
Applied {
previous_revision,
target_revision
}
AlreadyApplied {
target_revision
}
RevisionConflict {
expected_revision,
current_revision
}
OwnershipConflict {
instance_id
}
```
Validation error 与 database error 保持独立类型。HTTP handler 只负责将这些结果
映射到 section 4.3 的状态码。
## 6. Receiver implementation
### 6.1 Validation and normalization
在打开 SQLite write transaction 之前完成:
- request byte/count/per-entry limit
- UUID、重复 ID 和 upsert/delete 交集校验;
- config key 拼写归一化;
- envelope instance ID 覆盖 nested identity
- `NetworkConfig` 反序列化。
这样非法大请求不会长时间占用 SQLite writer lock。Ownership 必须在 transaction
内重新查询,因为 transaction 外的结果可能已过期。
当 request 带 target revision 时,可以先做一次 O(1) revision read;如果当前值
已经等于 target,可直接返回 `AlreadyApplied`,避免完整 config 归一化。任何可能
写入的请求仍必须在 transaction 内再次检查 revision。
### 6.2 Full transaction
在同一个 SQLite connection 上执行:
1. `BEGIN IMMEDIATE`
2. 读取 `(user_id, machine_id)` 当前 persisted revision。
3. 若 supplied target 已经是 current,返回 `AlreadyApplied`
4. 检查 optional expected revision。
5. 只读取现有 row 的 `(instance_id, source)`;不加载无关 config JSON。
6. 执行 user-owned ownership fence。
7. 批量 upsert 全部 desired web-owned rows。
8. 计算并批量删除 `existing_web_ids - desired_ids`
9. 最后写入 supplied target revisionlegacy unrevisioned Full 则删除旧 revision。
10. Commit。
任一步骤失败都 rollback。Full 仍是 `O(total)`,但不会再逐条独立提交,也不会
出现“部分 rows 已更新、revision 仍是旧值”的中间持久状态。
### 6.3 Patch transaction
在同一个 SQLite connection 上执行:
1. `BEGIN IMMEDIATE`
2. 读取 current revision。
3. 如果 current 等于 target,返回 `AlreadyApplied`
4. 如果 current 不等于 expected,返回 `RevisionConflict`
5. 只查询 upsert/delete IDs 的 source。
6. 任一 touched ID 属于 user 时,返回 `OwnershipConflict`
7. 批量 upsert changed configs。
8. 批量删除 requested web-owned IDs。
9. 最后写入 target revision。
10. Commit。
Patch 禁止:
- list 全部 Target rows
- 重算完整 Target digest
- 根据 touched IDs 之外的数据做 stale-row scan。
因此其数据库工作量只随 `upserts + deletes` 增长。
### 6.4 Bounded batch SQL
批量操作不构造无限长 SQL。根据 SQLite bind-variable limit 选取固定 batch size
并在同一个 transaction 内分批执行:
- multi-row `INSERT ... ON CONFLICT DO UPDATE`
-`source = web` 条件的 batch delete
- 只返回 instance ID/source 的 ownership query。
Patch statement 数量应为 `O(ceil(delta / batch_size))`Full 为
`O(ceil(total / batch_size))`。每个 accepted request 只有一个 transaction 和
一次 revision 写入。
### 6.5 Alternate-write revision invalidation
现有其他路径可能 save、delete、disable 或改变 web-owned row。若这些路径修改
rows 后仍保留旧 managed revisionPatch CAS 会把错误状态当作正确 base。
因此所有 config mutation Adapter 必须遵守:
1. 判断 mutation 是否改变 web-owned row
2. 在一个 transaction 中执行 mutation
3. 在 commit 前删除该 Target 的 managed revision。
Managed Full/Patch 在同一 transaction 内先完成 mutation,最后写入新的 target
revision。只影响 user-owned rows 的操作不清除 managed revision。
本方案不在 `/validate-token` 读取 revision 时重算完整 digest,否则周期性验证会
重新变成 `O(total)`。Revision 完整性由所有写入 Adapter 局部维护。
### 6.6 Locking, cancellation and notification
现有 per-target process-local lock 可以保留,用于减少同进程的重复工作,但它不
承担正确性。正确性由 SQLite transaction 和 CAS 提供。
- Transaction 内不执行 Session RPC、网络请求或无关 async 工作。
- HTTP future 在 commit 前取消时,transaction drop 必须 rollback。
- Commit 后即使 response 或 notification 丢失,persisted state 仍然有效;调用者
用同一 target retry 会得到幂等成功。
- 只有带 target revision 的 `Applied` 才通知匹配的 live Session
`AlreadyApplied`、legacy unrevisioned Full、conflict 和失败不重复通知。
- Notification 必须发生在 commit 之后。
- Full notification 将 pending reconcile hint 提升为 Full,触发完整收敛。
- Patch notification 携带 expected revision、target revision、upsert IDs 和本次
transaction 实际接受删除的 web-owned IDs。请求删除但数据库原本不存在的 ID
仍是 no-op,不能借机删除 Core 中同 ID 的 user-owned 实例。
- Session 将尚未应用的 Patch touched IDs 合并为一个 Dirty set,并始终以 SQLite
最新 revision 下的 rows 为准。它不重放历史 Patch,也不维护 Patch queue 或
delivery FSM。只有 incoming expected 等于 pending target 的通知才能合并;Dirty
hint 保留最早 expected 和最新 target。乱序、不连续或无法证明顺序的通知将 hint
提升为 Full。多个连续 Patch 积压时,旧 round 由 runtime epoch 拦截,下一 round
直接收敛到最新 target。
- Session 分开记录对外报告的 applied revision 和内部可信的 runtime base。开始任何
runtime side effect 前清除 appliedPatch round 的 side effects 完全包含在 Dirty
set 中,因此失败或被新通知拦截时仍保留最早 runtime base,以便按最新持久化状态
重试 Dirty set。Full round、direct mutation、授权失败或 Session ownership 中断会
清除 runtime base。
- 只有可信 runtime base 等于 Dirty 最早 expected,并且 SQLite persisted revision
等于 Dirty 最新 target 时,才允许增量 round。重连后 runtime base 未知、通知
丢失,或 SQLite 已经提交了更靠后的 revision 而通知尚未送达时都回退 Full,避免
不完整的 Dirty set 把完整 target revision 误标为已应用。
- 增量 round 逐个读取 Dirty set 中的最新 row。仍然存在且启用的 web-owned row
使用其最新 config;已经删除的 row 进入 delete set;遇到 disabled 或非 web-owned
row 时回退 Full,以保留 ownership 规则。完成前再次校验 persisted target
revision;只有全部 touched instances 成功且 target 仍相同,才推进 applied
revision。
- 任何通过 EasyTier Web mutation route 直接 Run、Save、Delete 或切换实例状态的
操作在执行前和结束后(包括部分 side effect 后返回错误)都清除 Session applied
revision、可信 runtime base 与 pending hint,增加运行配置 cache epoch,并唤醒一次 Full
reconcile。旧 round 只有 epoch 仍匹配时才能推进 applied revision;新一轮不得
信任 mutation 前缓存的 runtime config。否则 runtime-only mutation 或 Core 成功、
SQLite 失败的复合 mutation 可能在 persisted revision 不变时破坏 Patch base 的
完整性。
## 7. Capacity contract
当前 route 没有显式 body limitAxum `Json` 使用依赖版本的默认 2 MiB 限制。
生产容量不应依赖框架隐式默认值。
本阶段定义并测试四个独立限制:
- decoded request 最大 bytes
- Full entries / Patch upserts 最大数量;
- Patch deletes 最大数量;
- 单个 `network_config` 最大 bytes。
限制只应用于 internal managed-config route,不提高其他 public route 的 limit。
具体默认值不能拍脑袋确定:先采集 1k/10k representative configs 的 encoded
size 和 peak memory,再选择有明确 headroom 的默认值及硬上限。
提高 Full limit 只是确保 fallback 覆盖已支持的生产规模,不是稳态优化。请求压缩
同样只能降低 wire bytes,不能降低 JSON materialization 和 SQLite 工作量,因此
不作为 Patch 的前置条件。
## 8. Failure and recovery
| Failure | Receiver state | Caller action |
| --- | --- | --- |
| Invalid payload | Unchanged | 修复请求,不重试相同 payload |
| Capacity exceeded | Unchanged | 使用较小 PatchFull 需检查支持规模 |
| Revision conflict | Unchanged | 重新观察;有 base 时重算 Patch,否则 Full |
| Ownership conflict | Unchanged | 解决 ownership,不能自动覆盖 |
| SQLite error before commit | Rolled back | 从相同 observed revision 重试 |
| Response lost after commit | Target committed | 同一 target retry,幂等成功 |
| Process exits before revisioned Session notify | Target committed | 现有 revision reconciliation 恢复 |
| Alternate web-row mutation | Revision atomically cleared | 下一次观察触发 Full 修复 |
| Console cache loss | Receiver unchanged | Console 发布 Full |
Receiver 不保存 Patch delivery ledger。Publisher 根据自己的完整目标和 receiver
当前 revision 重算 Patch 或选择 Full。
## 9. Rollout and rollback
### 9.1 Receiver-first rollout
1. 为现有 Full 行为增加 characterization tests。
2. 将 Full rows/revision 改为一个 atomic transaction。
3. 为 alternate web-row mutation 增加 revision invalidation。
4. 增加 PATCH、typed conflict、capacity limits 和 metrics。
5. 在 Console 仍只发送 PUT 时部署到全部 EasyTier Web 实例。
6. 完成旧 Console PUT、新 Console PUT/PATCH contract 测试。
7. 最后启用 Console Patch 发布。
Patch capability 不通过 `/validate-token` 协商。部署顺序就是 compatibility gate
这样不会把配置能力重新耦合回鉴权 Interface。
Console 遇到 409 可以 re-observe 后发送 Full。它不能把 404、401 或 malformed
response 当作旧 receiver 并静默换一种 mutation contract;出现 404 表示接收端
部署门禁未满足。
### 9.2 Rollback
- Console 尚未发送 Patch 时,EasyTier Web 可正常回滚。
- Console 已发送 Patch 后,先回滚 Console,使调用恢复为 PUT,再回滚 Web。
- PUT 在整个发布周期保持兼容。
- Patch 和 Full 写入相同 rows/revision,不需要格式级数据迁移。
本方案不新增 persistent table。现有 Target/instance unique index 应覆盖 touched-ID
查询;若实现时需要新 index,必须先用实际 SQLite query plan 证明。
## 10. Verification
### 10.1 Contract tests
- 现有 Full JSON 继续接受。
- 空 Full 删除所有 web-owned rows,保留 user-owned rows。
- Patch add/update/delete 与等价 Full 得到相同最终 projection。
- Duplicate/overlap/invalid config 返回 400 且零写入。
- Patch 缺少 revision 返回 400。
- Revision conflict 返回 409 和 current revision,不返回 config。
- Byte/count/per-entry limits 分别有确定性测试。
### 10.2 Transaction and ownership tests
- 在 upsert 后、delete 后、revision write 前注入错误,rows/revision 全部 rollback。
- Revisioned Full/Patch 的 user-owned collision 整体 rollback。
- 删除不存在的 ID 幂等成功。
- 两个 target 从同一 base 并发时,一个成功、一个 409。
- 相同 target retry 只有第一次写入,第二次为 no-op success。
- Alternate save/delete/disable web row 与 revision invalidation 原子提交。
- User-owned-only mutation 不清除 managed revision。
- 数据库重连后,任一 persisted revision 都对应完整一致的 rows。
### 10.3 Scale tests
至少使用 1k 和 10k representative entries
- 单实例 Patch 的 decoded bytes、row reads、writes 和 statement count 不随 Target
总实例数增长;
- Patch 不执行 list-all query
- Full 使用 bounded batches 和一个 transaction
- Revision read 保持 O(1)
- 超限 Full 稳定返回 413/422,而不是耗尽进程内存;
- 并发请求无 deadlock,且 CAS 结果确定。
Session 测试还必须验证:连续 Patch 的 Dirty IDs 会合并且保留最早 expected;未知
或不匹配的 runtime base、乱序/不连续通知使用 Full;增量 round 读取最新 row;已经
删除的 web-owned row 只删除对应 Dirty IDFull notification 覆盖 Dirty hint;目标
revision 已变化或 touched runtime apply 失败时不推进 applied revision;直接 runtime
mutation 使 revision 与运行配置 cache 同时失效。运行态 Config Get/Patch/Run/Delete
数量应随 touched instances 增长。为确认运行实例身份而进行的一次 list/meta RPC
可以保留,它不发送或重写所有实例配置。
## 11. Observability
每个请求记录结构化字段,但不记录 config 内容:
- mode`full` / `patch`
- user/machine scope
- request bytes
- desired/upsert/delete count
- normalization、target-lock wait、transaction duration
- SQL statement/batch count
- resultapplied、already-applied、revision-conflict、ownership-conflict、
invalid、oversized、database-error
- Session notification 是否发送。
Rollout acceptance
- Console 启用后 Patch 占普通变更的绝大多数;
- 单实例变化的 request size 与 SQLite cost 与单实例成比例;
- conflict rate 可解释且稳定;
- 支持规模内的 Full 没有 413/422
- validate-token latency 不随 Target 实例数增长。
## 12. 后续优化
### 12.1 Session runtime delta apply(已实现)
Patch commit outcome 已携带 expected、target 和 transaction 实际 touched IDs。
Session 只合并 expected/target 连续的 Dirty IDs,并在每一轮从 SQLite 读取最新
target revision 对应的当前 rows;因此正常积压只增加 Dirty set,不需要保留中间
revision 的 Patch queue。可信 runtime base 必须等于 Dirty 最早 expectedPatch
side effect 失败可保留该 base 重试,通知丢失、乱序、进程重启或新 Session 尚无
runtime base 时回退 Full。Full
notification、disabled row 或 ownership 无法证明时也回退 Full。
### 12.2 Chunked Full
不能把 Full Exact Set 直接分页写入 live rows:接收端无法在中间页判断哪些旧
实例最终应删除,crash 也会暴露半套目标。
如果测量证明单请求 Full 无法覆盖必须支持的冷恢复规模,需要单独设计带
snapshot ID、staging rows、expiry、finalize 和 atomic swap 的协议。在出现数据
证明前不新增该状态机。
## 13. Implementation files and checklist
主要涉及:
- `easytier-web/src/restful/network.rs`
- `easytier-web/src/client_manager/mod.rs`
- `easytier-web/src/client_manager/managed_config.rs`
- `easytier-web/src/db/mod.rs`
- 对应 contract、database 和 managed-config tests
完成条件:
- [x] 现有 Full compatibility tests 固定。
- [x] Full config rows 与 revision 原子提交。
- [x] Alternate web-owned mutations 原子清除 revision。
- [x] PATCH contract 和 typed 409 实现。
- [x] Patch 只查询、写入 touched IDs。
- [ ] Bulk SQL 遵守 tested bind-count bound。
- [ ] Route byte/count/per-entry limits 有文档和测试。
- [x] User-owned rows 不能被 Full/Patch 覆盖或删除。
- [x] Empty Full 语义保持。
- [x] Applied/AlreadyApplied/conflict 的通知行为符合设计。
- [x] Session 在 revision 连续时只收敛 touched instances,断链时使用 Full。
- [ ] 1k/10k scale 与 concurrent CAS tests 通过。
- [ ] Receiver-first compatibility matrix 通过。
@@ -0,0 +1,470 @@
# KCP 控制报文可靠性与旧版兼容设计
日期:2026-09-14。
状态:已按本文实现并提交;当前实现、验收结果及仍未定位的异常见
[实现与验证记录](kcp-control-reliability-validation-2026-09-14.md)。
本文保留设计要求,具体通过范围以验证记录为准。
## 1. 背景与问题边界
此前已修复 TCP 代理任一方向 EOF 导致双向转发提前退出、KCP accept
通知队列满后连接交接丢失、接收缓冲排空与提前 FIN 的处理,以及握手前
心跳触发 RST 的竞态。具体版本及结果见
[半关闭验证记录](tcp-proxy-half-close-validation-2026-09-14.md)。
仍有两个问题:
1. 已证实的握手最终 ACK 丢失:源端收到 SYNACK 后认为连接建立,目标端
仍停留在 SynReceived;服务端先发 greeting 的业务因此超时。
2. 一次未主动注入丢包的反向半关闭超时:发送端出现输出队列 Full,FIN
已进入端点输出队列,但没有证据证明它何时到达对端。失败日志未记录
客户端已收字节数,尚不能确定缺少的是数据、EOF,还是两者。后续
TRACE 重跑通过,不足以消除或解释原失败。
KCP 数据传输自身的确认重传没有覆盖外层 SYN、SYNACK、握手确认和 FIN。
这是连接状态机的局部协议设计问题。修复需要明确控制报文的重复处理、
完成条件和状态寿命,无需重做 KCP 数据传输或 EasyTier 路由架构。
本设计解决已证实的握手恢复缺口,并为 FIN 增加可靠确认。同时继续调查
第二项异常;不能将新增 FIN 重传直接当作该异常已经修复的证明。
## 2. 目标、范围与不变量
目标:
- 新旧节点双向正常互通,滚动升级不得要求所有节点同时升级。
- 新节点之间,控制报文在重试预算内丢失、重复或乱序时能够恢复。
- 连接只交接一次,应用数据不重复交付,EOF 不越过已接收数据。
- 半关闭只关闭一个发送方向,另一方向仍可长期传输。
- 无法恢复时有界结束并报告错误,不能用正常 EOF 掩盖失败。
- 关闭记录、待发送控制报文和重试任务均有明确生命周期。
范围:kcp-sys 的报文定义、连接状态机、端点调度与清理、必要的流错误
传播,以及 EasyTier 的依赖 pin、集成测试和验证记录。
不新增路由能力公告、PeerFeatureFlag 字段、配置开关或应用层请求重试;
不修改 TCP flow-key 修复、QUIC 协议、KCP 数据分段和拥塞算法。保留现有
connect API、调用方超时和业务测试的 5 秒 socket 超时。
协议不变量:
1. 模式由本连接握手确定,交付应用后不能切换。
2. 握手状态变化和是否产生应答是两件事;可靠模式允许状态不变但应答。
3. 已经进入应用交接路径的连接,重复控制包不能再次 accept。
4. FIN_ACK 只确认对方 FIN,不表示自己也关闭发送方向。
5. 只有正常收到对端 FIN 且排空接收数据,才能向应用报告正常 EOF。
6. FIN 确认成功后,FIN 重试期限不能限制正常半关闭连接的寿命。
7. 重复包不能延长握手、FIN 重试和最终关闭记录的固定截止时间。
## 3. 版本基线与兼容证据
| 用途 | kcp-sys revision |
| --- | --- |
| 旧协议基线 | `d7427c22d764deb1860a7d37acc446ed5033464c` |
| 本设计实施基线,已含前述局部修复 | `b37ee660fb70bb6d816fb8bbc08b140e55e7218b` |
源码检查确认:
- 旧 header 含三个 u32、一个 flags 字节及一个 `rsv` 保留字节,总长
14 字节;旧实现不读取 `rsv`
- 旧 SYNACK 使用新建且清零的 header,不回显 SYN 的 `rsv`
- 旧 FSM 收到重复 SYN 时可能构造 RST,但状态不变;外层
`KcpConnectionState::handle_packet` 丢弃状态未变化时的输出,
因此已检查的旧端点不会实际发送该 RST。
- `rsv` 未被读取、状态不变时抑制输出的行为,在已检查历史
`a5f4b4e``37f653c``0f2fea1``9ce5c08``0f0a055`
`71eff18``d7427c2` 中一致。
这些是源码与历史检查,不是上述每个 revision 的运行测试。正式兼容
声明必须对应实际测试的 EasyTier 版本及其锁定依赖,不能扩展为所有
未检查的历史版本或第三方修改版本都已验证。
实现基线源码:
[报文定义](https://github.com/EasyTier/kcp-sys/blob/b37ee660fb70bb6d816fb8bbc08b140e55e7218b/src/packet_def.rs)、
[状态机](https://github.com/EasyTier/kcp-sys/blob/b37ee660fb70bb6d816fb8bbc08b140e55e7218b/src/state.rs)、
[端点](https://github.com/EasyTier/kcp-sys/blob/b37ee660fb70bb6d816fb8bbc08b140e55e7218b/src/endpoint.rs)。
## 4. 报文格式与连接内协商
### 4.1 字段定义
保持 14 字节 header、连接标识字段、已有 flags 和 KCP DATA payload
布局不变。使用已有 `rsv` 字节:
| 值 | 定义 |
| --- | --- |
| `0` | 旧协议,后文称 legacy |
| `1` | 本文可靠控制协议,后文称可靠模式 |
新增 FIN_ACK 使用 flags 当前未分配的最高位 `0x80`。它是独立报文,
flags 必须恰好为 FIN_ACK`rsv=1`payload 为空。不能用 FIN|ACK
表示关闭确认,因为旧端会把 FIN 位解释为对端关闭发送方向。
可靠模式报文约束:
| 报文 | flags | payload |
| --- | --- | --- |
| SYN | SYN | 原有连接元数据,重传保持完全相同 |
| SYNACK | SYN\|ACK | 空 |
| 最终握手确认 | ACK\|DATA | 空 |
| 数据及 KCP 内部确认 | ACK\|DATA | 原有 KCP payload |
| 关闭发送方向 | FIN | 空 |
| 关闭确认 | FIN_ACK (`0x80`) | 空 |
| 重置、心跳 | 原有 RST、PING、PING\|PONG | 沿用现有定义 |
可靠模式报文均带 `rsv=1`。legacy 的后续报文仍使用现有格式和 `rsv=0`
新源端最初提出协商的 SYN 是可能发给旧目标端的唯一保留字节扩展。
只有匹配连接标识、当前握手阶段和合法 flags/payload 的 SYNACK
才可以确认模式。不能通过 PING/PONG 确认:旧端的心跳应答会复制输入
报文,可能保留它并不理解的 `rsv`
### 4.2 协商步骤
1. 新源端发送 SYN`rsv=1`,进入尚未确认模式的 SynSent。
2. 新目标端收到 SYN`rsv=1` 时回复 SYNACK `rsv=1`,记录可靠模式
候选状态;`rsv=0` 时从头使用 legacy,回复 SYNACK `rsv=0`
3. 旧目标端忽略 `rsv`,自然回复 SYNACK `rsv=0`。新源端收到它后,
在交付应用前确定 legacy,最终 ACK 及后续报文都使用旧模式。
4. 新源端收到合法 SYNACK `rsv=1` 后确认可靠模式,发送最终 ACK
`rsv=1`。目标端收到匹配模式的确认后完成握手并登记待 accept。
5. 源端继续在收到合法 SYNACK 后完成 connect,不增加第四次握手或
额外 RTT。最终 ACK 丢失后的恢复规则见第 5 节。
未识别的 SYN `rsv` 值不得被当作可靠能力,可按不支持扩展响应 legacy
SYNACK。新源端只接受它实际支持的 SYNACK 模式 `0``1`;未知值
报告协议错误,不启用增强行为。重复 SYN 不允许更改已有连接的模式或
连接元数据。已协商可靠模式后,模式不匹配的控制包不得触发降级。
节点重启或降级后的新连接重新协商;不缓存按节点永久生效的协议模式。
不承诺跨进程重启维持既有连接。
### 4.3 初始 SYN 与旧版兼容
首个 SYN 或 SYNACK 丢失时,源端尚不可能拿到协商确认。因此 SynSent
期间允许在原截止时间内重传完全相同的 SYN,不以前置能力公告或已收到
SYNACK 为条件。
在已检查的旧端点中,重复 SYN 不引出 RST,但也不会让旧目标端重发
SYNACK。所以新源端→旧目标端仍保留旧目标端 SYNACK 丢失时的限制。
这一行为必须由真实旧依赖运行测试确认,不能只测试重写的 legacy 模型。
新目标端对旧源端保持 legacy 行为,不主动开启 SYNACK 重传。
legacy 分支继续保留“状态不变则抑制 FSM 输出”的现有行为;不得将
可靠模式的应答修复无条件套用,否则会改变旧连接的 RST 行为。
## 5. 可靠握手与重复报文
源端在 SynSent 重传 SYN,目标端在可靠 SynReceived 重传 SYNACK。
所有重传使用原连接 ID,不创建额外连接或重试业务请求。
| 当前状态 | 输入 | 动作 |
| --- | --- | --- |
| 无状态 | 合法 SYN | 保存模式与连接元数据,建立 SynReceived,回复 SYNACK |
| SynReceived | 相同 SYN | 重新回复 SYNACK,不替换状态或延长截止时间 |
| SynReceived | 匹配的 ACK\|DATA | 完成握手,登记待 accept 一次,停止 SYNACK 定时重传 |
| SynSent | 合法 SYNACK | 确定模式,发送最终 ACK,完成 connect |
| Established 或半关闭 | 重复 SYNACK | 回复最终 ACK,不重新创建流、不改变关闭状态 |
| 已握手、尚未最终释放 | 相同 SYN | 可重发原 SYNACK,不重复 accept、不恢复已关闭方向 |
| 已握手 | 重复最终 ACK | 幂等处理,不重复交接连接 |
ACK\|DATA 带有效 KCP payload 时仍按 KCP 处理。若它同时完成握手,
不得为了修复握手而重复交付 payload;必须验证 accept 前到达数据时,
现有 KCP 重传和接收路径最终完整交付字节。
最终 ACK 丢失的正常恢复:
```mermaid
sequenceDiagram
participant S as 源端
participant D as 目标端
S->>D: SYN
D->>S: SYNACK
S--xD: 最终 ACK 丢失
Note over S: connect 已返回
Note over D: 保持 SynReceived
D->>S: 重传 SYNACK
S->>D: 重发最终 ACK
Note over D: 登记待 accept 一次
```
### 5.1 最终 ACK 丢失后立即 FIN
空流的发送缓冲本来就是空的,connect 返回后可以立即 shutdown;不能
假设 FIN 一定晚于最终 ACK 到达。
可靠 SynReceived 收到匹配模式和连接标识的合法 FIN 时,将它视为
隐含的握手确认,同时执行:
1. 完成握手并停止 SYNACK 重传。
2. 登记待 accept 一次,保存对端发送方向已关闭。
3. 回复 FIN_ACK;accept 创建流时继承对端关闭状态。
4. 接收排空后报告 EOF,本端仍可回复数据。
不能先进入 Closed 再返回 RST,也不能要求应用先发送非空数据才能
完成握手。该规则仅用于可靠模式。
## 6. FIN、确认与双向关闭
### 6.1 本端关闭发送方向
保留现有顺序:应用关闭发送队列,发送任务把排队数据交给 KCP,并等待
`waitsnd == 0`,然后进入 FIN 待确认阶段。重试预算从这一阶段开始计算,
不能从应用开始传输或尚未排空数据时开始计算。
FIN 发送入队成功不代表对端已收到。记录待确认状态、下一次重传时间和
固定截止时间,直到收到有效 FIN_ACK 或连接明确失败。收到对端 FIN
本身不等同于本端 FIN 已被确认。
保留现有 AsyncWrite shutdown 的本地关闭语义,不将其返回成功解释为
对端已确认。关闭确认和最终回收由端点负责;后续超时通过仍存活的流和
诊断状态表达,不能变成伪造的正常 EOF。
### 6.2 收到 FIN 或 FIN_ACK
- 收到 FIN:记录对端发送方向已关闭,并安排 FIN_ACK。接收任务仍需
把已确认接收的数据交给应用,排空后才产生 EOF。
- FIN_ACK 可以先于应用读完数据发送;它确认端点收到了 FIN,不确认
应用已经消费数据。因此接收状态和缓冲生命周期不能随确认提前删除。
- 重复 FIN:重复应答 FIN_ACK,不重复产生应用 EOF,不重置连接。
- 收到 FIN_ACK:仅当本端确实已经发送 FIN,且模式、连接标识、flags
和 payload 全部合法时,停止本端 FIN 重传。
- 提前、重复或与当前阶段无关的 FIN_ACK 不推进状态、不关闭写方向。
发送和接收两个方向分别记录完成情况。现有 Established、LocalClosed、
PeerClosed、Closed 不足以单独表达“本端 FIN 已发但未确认”,应在现有
连接状态内补足该信息,不另建一套连接所有权或通用重试框架。
### 6.3 正常回收与短暂关闭记录
可靠模式最终回收分两步:
1. 本端 FIN 已确认、对端 FIN 已收到,并且接收任务排空后,释放 KCP
数据对象、应用通道和数据任务;在原 state_map 保留最小关闭记录。
2. 固定保留期结束后删除该记录。重复报文不延长保留期。
关闭记录仅保存原连接 ID、协议模式、正常关闭原因、回复重复 FIN 所需
信息和过期时间。它继续回复 FIN_ACK;合法在途 PING/PONG 可以忽略,
不得因现有 Closed 心跳路径而回复 RST。迟到 SYN/SYNACK 不得重新
accept、创建数据对象或恢复连接;已经完成的数据包可以丢弃。
双方同时关闭时,即使只有一方收到 FIN_ACK,先完成的一方也要依靠该
记录继续回答另一方的 FIN 重传。不能沿用“Closed 且 recv_done 就立即
删除所有状态”的判断。
legacy 继续使用原有回收规则。可靠模式的短暂记录会使状态表在有限
时间内多保留条目,容量验收必须单独计算,不能继续要求所有内部状态
在原 15 秒观察点清零。
## 7. 定时、队列与失败路径
### 7.1 拟定默认参数
以下为实现初值,需由定点丢包与延迟测试验证后固定;调整须在验证记录
说明,不能为了让测试通过而延长业务超时。
| 参数 | 初值 / 规则 |
| --- | --- |
| SYN、SYNACK、FIN 首次重传间隔 | 200 ms |
| 后续重传 | 指数退避,单次间隔上限 2 s |
| 源端握手期限 | 调用方现有 `connect(timeout_dur)`,不延长 |
| 可靠目标端握手期限 | 首次收到 SYN 后 60 s,不由心跳或重复包续期 |
| FIN 待确认期限 | 进入 FIN 阶段后 60 s |
| 正常关闭记录保留期 | 65 s,覆盖 FIN 重试窗口并留调度余量 |
目标端和 FIN 的 60 秒预算是端点控制状态的资源边界,不替代应用更短的
超时。网络永久不可达时,正确结果是可诊断的超时和最终清理。
关闭记录保留期必须不少于另一端允许的最大 FIN 重试窗口加余量,因此
FIN 期限不能变成各端任意可调且互不知情的参数。若后续版本改变这些
上界,需要重新审查版本间兼容性。
FIN 已确认后立即解除 FIN 重试期限;正常半关闭继续按既有连接存活规则
处理。仅本端 FIN 未确认才受该期限限制,反向有数据或 PONG 也不能使
未确认 FIN 无限续期。
### 7.2 端点调度与输出队列
使用单个端点控制调度任务和现有 state_map;不为每次重传创建任务,不
扩容或改成无界队列。按最近的控制截止时间唤醒,新状态通过通知更新
调度;没有待处理控制状态时,避免对全部空闲连接进行高频扫描。
状态记录是控制报文待处理事实的来源。输出队列暂满时保留该事实,稍后
再尝试;不能像原 accept 通知一样丢失后就再无恢复机会。
- 用短临界区和非阻塞入队,禁止持有 state_map/conn_map 锁跨 await。
- 每次实际入队前重新检查状态、连接标识和期限,防止陈旧重传在取消或
回收后继续发送。回收不能与入队检查形成复活旧连接的竞态。
- SYN/SYNACK/FIN 的重传受各自固定预算限制。重复请求所需的 ACK 或
FIN_ACK 应答可合并为待发状态,不能累计无界报文列表。
- 应答暂时无法入队时由调度器继续尝试;应答不会获得独立的无限寿命。
合并应答记录从首次待发起最多保留 60 秒,重复请求不续期;正常关闭
记录中的应答还受该记录更早的截止时间限制。入队成功即撤销待发标记。
单纯的应答待发过期只清除该应答,不因此关闭仍合法存活的连接;请求
发送方最终依靠自身握手或 FIN 重试期限判定成功或失败。
- 队列满不能阻塞整个控制调度任务,也不能阻止它处理其他连接的截止
时间。不得把“尝试入队成功”统计成“对端确认成功”。
### 7.3 取消、错误与连接 ID
- connect future 取消或超时:撤销该连接的重试与本地所有权;目标端
未完成握手的状态按其固定期限退出,重复包不能延长。
- 有效 RST、端点销毁或 FIN 重试耗尽:停止控制重试、唤醒等待任务,
保留错误与正常 EOF 的区别。只发送必要的现有重置通知,不建立 RST
确认和重试协议。
- 清理失败连接不能重新产生待 accept;迟到的控制响应不能恢复已取消
的 connect future。
- ConnId 分配必须避开所有仍有效的状态,包括正常关闭记录。回绕不能
复用仍在保留期内的 ID;会话标识变化后,旧报文不得作用于新会话。
- 保留期之后无限迟到的包不在可靠恢复窗口内。不得通过永久保存关闭
记录来试图覆盖无界的网络延迟。
## 8. 兼容性矩阵与降级
| 源端 | 目标端 | 预期模式 | 必须满足 |
| --- | --- | --- | --- |
| 旧 | 旧 | legacy | 对照基线,保留既有行为 |
| 新 | 旧 | SYNACK `rsv=0` 确定 legacy | 正常互通,不发 FIN_ACK,不要求目标支持新确认 |
| 旧 | 新 | 从 SYN `rsv=0` 确定 legacy | 正常互通,不主动开启新 SYNACK/FIN 重传语义 |
| 新 | 新 | 本连接握手确认可靠模式 | 握手与关闭在重试预算内恢复 |
兼容的含义是旧节点仍可互通,且混合组合不因新代码发生行为退化。它不
意味着旧目标端自动获得 accept 修复,或旧连接获得其未实现的 ACK/FIN
恢复能力。必须把遗留失败与新增失败分别记录。
发布或回退影响后续新连接的协商。协议模式固定在连接内,禁止基于后续
路由公告变化对活跃连接进行升级或降级。旧可执行程序重启后不承担继续
解释原进程可靠模式连接的义务。
## 9. 实施拆分
实施使用独立协议修复分支,以 `b37ee660` 及 EasyTier 当前已验证修复
为基线,不将协议改动混入原 TCP flow-key patch。
1. **先建立兼容测试夹具。** 使用实际旧依赖和当前基线端点,确认 `rsv`
处理、旧 SYNACK、重复 SYN 和旧模式输出门控。明确失败基线。
2. **实现协商及状态处理。** 增加模式、合法报文校验、可靠模式幂等应答,
保留 legacy 路径。补上最终 ACK 丢失加立即 FIN 的交叉状态。
3. **实现统一控制调度及可靠关闭。** 补足握手/FIN 期限、FIN_ACK、正常
关闭记录、队列暂满、取消和错误清理。
4. **完成依赖验证后接入 EasyTier。** 更新依赖 pin 与锁文件,运行真实
代理路径、混合版本和跨平台验证,提交可追溯记录。
协商声明代表完整可靠控制协议,不能在只实现握手恢复、尚未实现 FIN_ACK
时对外声明 `rsv=1`。中间提交可以用于审阅,但仅完整实现且验证通过的
依赖版本可被 EasyTier 发布使用。
代码主要落点为 kcp-sys 的 `packet_def.rs``state.rs``endpoint.rs`
按实际错误传播需要调整 `stream.rs`;不预先拆出通用协议框架。EasyTier
预计只需依赖更新、测试和记录,不需要修改 protobuf 或节点能力公告。
## 10. 验证与验收标准
### 10.1 确定性协议测试
在端点输入/输出之间设置测试用报文过滤器,按连接 ID、报文类型和次数
精确丢弃、延迟或重复报文。可使用可控时钟测试期限,不依赖随机 netem
才能触发边界,也不通过扩大生产超时使测试通过。
| 场景 | 验收断言 |
| --- | --- |
| 分别丢首个 SYN、SYNACK、最终 ACK | 新新组合恢复,同一连接只交接一次 |
| 连续丢多次控制包后恢复链路 | 截止时间内恢复,重试次数与退避符合预期 |
| 最终 ACK 丢失后空流立即 FIN | 目标完成握手、返回 EOF、仍能回复数据,无 RST |
| 最终 ACK 丢失后首个 DATA 到达 | 正确完成握手,应用字节完整且不重复 |
| 重复 SYN/SYNACK/ACK,含半关闭阶段 | 不重复创建连接,不恢复已经关闭的方向 |
| 丢单向 FIN 或 FIN_ACK | 重传恢复;确认不关闭另一方向 |
| 双方同时 FIN,单侧或双侧确认丢失 | 正确排空,正常关闭记录继续应答,最终回收 |
| FIN 先于 accept 或 connect 返回 | 空请求、空响应均正确得到 EOF |
| 接收缓冲尚未被应用读完 | FIN_ACK 不导致缓冲和数据任务提前释放 |
| FIN 已确认后持续半关闭超过 60 s | 反向仍可传输,不受 FIN 期限误杀 |
| 正常关闭记录收到重复 FIN、旧心跳 | 应答或忽略,无 RST,不延长固定保留期 |
| SYN/FIN 永久丢失、控制输出队列长时间满 | 到期退出,无无限任务或状态残留 |
| 队列临时满后恢复 | 待发事实不丢失,其他连接和清理继续运行 |
| connect 取消、RST、端点销毁与重试并发 | 不复活旧状态,等待者正确结束,无伪 EOF |
| ConnId 回绕、会话变化、迟到报文 | 不命中仍在保留期的旧 ID,不污染新会话 |
### 10.2 真实旧版兼容测试
测试夹具必须运行旧依赖的真实 endpoint,不能只在新实现上设置 legacy
标志代替旧版本。首次验收覆盖 `d7427c2``b37ee660` 与新实现的组合,
并记录 EasyTier 发布验证实际选用的旧二进制版本和哈希。
除四种组合的双向数据与关闭外,额外断言:
- 旧端收到 SYN `rsv=1` 仍按旧协议回复 SYNACK `rsv=0`
- 重复 SYN 不引出混合连接新增 RST;legacy 输出门控保持原有行为。
- 混合组合捕获不到 FIN_ACK;旧端不必识别任何新控制语义。
- PING/PONG、错误连接 ID、非法 flags 或未知 SYNACK 模式不能确认能力。
- 新连接在节点升级、回退后重新选择正确模式,不沿用节点级缓存。
- 旧版原有半关闭或丢包失败作为对照保留,不将它们写成新协议已通过。
### 10.3 EasyTier 流量与平台验证
Linux 需要 root 的测试在现有 `rust` 容器中运行。复用正常 target,权限
问题通过修复所有权解决,不另开编译目录。
- 完整三节点组合及 ACL、配置更新、端口转发、断连测试。
- TCP/KCP/QUIC × 内核/smoltcp 六种模式,检查未修改协议没有退化。
- 每组合 300 次短连接、16 并发、5 秒 socket 超时;KCP 每栈追加三轮,
与已有每版本 2,400 次 KCP 结果对照。
- 空请求、256 KiB 请求后 EOF、1 MiB 响应,以及服务端先半关闭后客户端
才发送 256 KiB 的反向场景,均逐字节核对。
- `netem delay 10ms 3ms loss 1%` 下持续双连接与短连接;定点控制包丢失
由协议测试证明,随机丢包实验用于验证整体行为。
- 新旧两方向真实二进制互通;无丢包与丢包结果分别记录。
- Linux、macOS、Windows 原生依赖测试及网关测试;格式与严格 Clippy。
缺少组件、未运行的项目不得记录为通过。
### 10.4 未解释的反向超时
复现脚本必须记录超时时已收到的字节数、是否收到 EOF、源端口、连接 ID,
并在双方记录 FIN/FIN_ACK 的入队、发送和接收时间。补足原失败缺少的
证据,区分数据缺失、关闭通知缺失、队列延迟和状态处理错误。
恢复实际链路的 FIN 丢失,只能证明这一类注入故障已修复;仍需解释原
异常或明确保留未定位项。不得用重跑通过覆盖原始失败。
### 10.5 资源与交付门槛
分别统计:应用代理连接、KCP 数据对象、半开握手、未确认 FIN、正常关闭
记录、FD 和 RSS。保留期内的轻量记录属于设计成本,不能混同于活跃连接
泄漏;其数量约受每秒关闭连接数乘以保留期约束,需实测内存成本。
静止并超过握手/FIN 期限及关闭记录保留期后,所有应回收状态必须消失。
使用重复负载周期检查资源是否持续累积,并检查端点空闲及大量并发时
控制调度的 CPU 成本。不能仅凭一次 FD 回到基线宣布不存在泄漏。
交付必须满足:确定性恢复用例通过;实际旧版兼容用例没有新增失败;
错误路径有界清理;原有矩阵没有新退化;所有异常如实保留。完整最终
代码 diff 由独立子代理审查,只处理高置信度真实缺陷。验证记录绑定
提交、依赖 revision、二进制哈希、准确命令及原始日志。
## 11. 当前进度与证据
已完成:连接级版本协商、握手与 FIN 恢复、FIN_ACK、重复控制处理、
正常关闭记录、队列饱和处理与取消清理。kcp-sys 最终提交为
`3ef5c4161faf99940f3ed51efd43cef0cbc02b4f`EasyTier 最终依赖接入为
`ee02b8f7`。首次验收已测试真实旧实现 `d7427c2``b37ee660`
后续测试整理仅保留 `d7427c2` 长期基线;当前维护版本与依赖 pin 见
[实现与验证记录](kcp-control-reliability-validation-2026-09-14.md)。
三平台原生依赖测试、Linux 完整矩阵及实际流量的本轮结果单独记录,
不沿用上一轮局部修复的通过数。最终代码审查没有 blocker / major
一项首次 SYNACK 调度竞态 minor 按用户规则记录待办。
仍需保留的边界:原反向超时没有足够证据做最终归因;混合版本保留
legacy 的控制恢复限制;有限负载和应用级资源采样不能代替生产规模
长期内存、CPU 和容量验证。所有实测异常及未执行项见关联验证记录。
此前调查原始产物位于:
```text
/data/project/proxy-close-validation-20260914/
```
关键证据:`remaining-loss-handshake.md``loss-handshake-evidence.log`
`delivery-reverse-timeout.md``fin-send-path-old-new.txt`
`delivery-manifest.json``traffic-summary.md`。上述绝对路径是本机
调查产物位置;仓库读者可通过关联验证记录了解结论与证据限制。
@@ -0,0 +1,296 @@
# KCP 控制报文可靠性实现与验证(2026-09-14)
本文记录
[协议设计](kcp-control-reliability-design-2026-09-14.md)
的实现与验证,独立于此前的 TCP flow-key 和半关闭局部修复。
## 兼容测试维护整理
当前维护版本为 kcp-sys `268533568d734ae89dc89603078da3ca522effe1`
保留 `d7427c22` 作为长期兼容基线,移除中间版本 `b37ee660`
`kcp-sys-baseline` dev-dependency 与三项重复用例;单次使用的宏展开为
普通测试函数。四项旧版兼容测试继续覆盖能力协商、双向数据、半关闭及
重复 SYN15 项 library 与 12 项协议回归不变。
整理后 Linux 共 31/31 测试通过,格式与严格 Clippy 通过。EasyTier
同步依赖 pin 与锁文件,并通过 `cargo +1.95 check --locked -p easytier
--features full`。此次只整理测试与测试依赖,协议源码未改变。
历史三平台 34/34 和全部流量结果仍属于下述 `3ef5c416` 实现验证,
没有重写为整理后版本的执行结果。中间基线作为调查证据保留在本文。
本次日志位于 `/data/project/kcp-compat-cleanup-validation/`
## 版本与实现
- kcp-sys 基线:`b37ee660fb70bb6d816fb8bbc08b140e55e7218b`
- kcp-sys 初版实现:`c84733d4479b40a299d51f4c5b8bb02ccacadc68`
- kcp-sys 最终实现:`3ef5c4161faf99940f3ed51efd43cef0cbc02b4f`,补齐
可靠模式下未知连接 RST 输出队列饱和时的非阻塞处理。
- EasyTier 基线:`851e7523`;接入提交:`4fedbdd1``ee02b8f7`
仅修改依赖 pin 和 Cargo.lock。
- 最终真实流量二进制:`final-easytier-core`SHA-256
`962f2480cb611ceb6cab293560d0dfb337218594cdc298a08f0e83801a2d17b8`
它在接入提交前构建,源码及依赖内容与该提交一致;识别产物以哈希和
依赖 revision 为准,不单凭内嵌的 EasyTier git 版本字符串。
依赖已发布到 `EasyTier/kcp-sys` 的独立分支
`fix/control-reliability-20260914`,远端引用核对为上述最终 revision。
EasyTier 任务分支为 `fix/kcp-control-reliability`
实现保留 14 字节 header,通过 SYN/SYNACK 的 `rsv` 协商可靠控制模式。
收到旧 SYNACK 后固定使用 legacy,旧源端连接新目标端也从头使用 legacy。
只有协商为可靠模式的连接使用独立 FIN_ACK 和新增恢复逻辑。
可靠模式在原连接 ID 上恢复 SYN、SYNACK 和 FIN;重复握手不重复交接。
最终 ACK 丢失后的空 FIN 可以同时确认握手并报告对端半关闭,反向仍能
发送数据。FIN_ACK 只确认收到 FIN,不关闭本端发送方向,也不提前释放
尚未被应用读取的接收数据。
控制请求和待发送应答在原连接状态中保存,由端点统一调度。输出队列
暂满不会丢失待发事实或延长固定期限;取消 connect 同步撤销本地状态。
状态检查、控制入队和清理保持一致锁序,不持锁跨 await。
初始重传间隔 200 ms、指数退避至 2 s;源端使用调用方连接超时,目标端
握手及 FIN 待确认期限为 60 s。正常双向关闭且接收排空后释放数据对象,
原状态表保留 65 s 的关闭记录,继续应答重复 FIN,并避免迟到心跳引出
RST。FIN 一旦确认,正常半关闭不受 FIN 重试期限限制。
## 自动化结果
| 验证 | 结果 |
| --- | --- |
| Linux kcp-sys | 15 library + 12 协议回归 + 7 真实旧依赖兼容,34/34 |
| macOS kcp-sys | 相同 34/34,两个 example target 通过 |
| Windows kcp-sys | 相同 34/34,两个 example target 通过 |
| Linux 两个 example target、格式、严格 Clippy | 通过 |
| macOS 格式、严格 Clippy | 通过 |
| Windows 格式、Clippy | 所选 stable 缺少组件,未执行 |
| Linux 原生网关测试 | 200/200 |
| macOS、Windows 原生网关测试 | 各 200/200 |
| EasyTier 完整三节点及补充集成测试 | 276/276834.639 s |
| EasyTier Linux 严格 Clippy | 通过 |
macOS 首次获取依赖遇到 GitHub TLS 错误,随后导入本机真实 Git 对象与
checkout,在依赖 revision 不变的情况下离线测试;没有改成 path 依赖,
也没有用新实现替换旧依赖。Windows 原始日志为 UTF-16LE,另保存 UTF-8
副本。两平台复用原 target,没有另开编译目录绕过权限问题。
协议修改集中在 kcp-sysEasyTier core 源码没有变化。原生网关验证检查
与现有相同 core 源码的兼容行为,不能替代上面的真实新旧 KCP 端点测试。
远端应用 manifest 仍锁定旧依赖 `d7427c2`,网关命令只选择 easytier-core
关键转发文件 `tcp_proxy_service.rs` 哈希与本地一致。因此不能将这组
网关测试描述为完整原生应用已经接入最终新依赖。
## 失败到通过的对照
- 丢弃第一份最终握手 ACK:基线客户端 connect 返回,服务端 accept
超过 5 s 仍未完成;实现后约 0.25 s 恢复,并成功发送服务端 greeting。
- 新源端连接真实旧依赖:基线仅因 SYN 尚未声明协商能力而未达到新协议
测试要求;实现后 SYN 提出 `rsv=1`、旧 SYNACK 回复 `rsv=0`,后续
数据与关闭均走 legacy。该项验证协商功能,不把它描述为旧版互通 bug。
- 原有 RST 单测曾在新连接上注入 `rsv=0` 的合成 RST,因可靠模式拒绝
不匹配模式而超时;测试改为注入该连接实际协商版本的 RST 后通过。
模式不匹配的报文不能用于证明正常 RST 错误传播失败。
- 新增双向大缓冲夹具最初单次写入超过既有 KCP send 的分片限制,出现
`Err(-2)`。调整为与原有测试一致的 16 KiB 分块,仍验证双向各
200 KiB 总数据和原 5 s 期限;本补丁没有修改既有单次大写入限制。
- 最终补查发现初版 `c84733d` 对未知可靠 FIN 的 RST 仍使用阻塞发送。
填满输出队列后,新 SYN 不能在 100 ms 内进入状态表;改为可靠模式
的无状态应答使用 try_send 后通过。需要重试的有状态控制仍由状态表
保管;legacy 发送路径不变。
原始失败日志保留,未使用扩大业务超时或重跑通过覆盖失败记录。
## 协议与生命周期覆盖
12 项公有 API 协议回归使用真实端点及按报文类型过滤的链路:
- 连续丢 SYN、连续丢 SYNACK、丢最终 ACK 后的服务端 greeting。
- 所有空最终 ACK 均丢失,空 FIN 直接完成握手,EOF 后仍可回复。
- 单向 FIN 或 FIN_ACK 丢失,另一方向仍可传输。
- 双向 200 KiB 缓冲、双方首个关闭确认丢失、延后读取与排空。
- 半关闭后重复 SYN/SYNACK/ACK;握手 ACK 丢失时 DATA 乱序、重复。
- FIN 确认后推进 61 s,再恢复实际时钟,仍可反向传输。
- PONG 保留 `rsv=1`、非法 SYNACK flags、非空 SYNACK 均不能确认模式。
- 未知 SYNACK version 返回 `InvalidProtocolVersion`,不启用新模式。
15 项 library 测试包含原有 10 项以及五项生命周期与队列测试:取消 connect
立即释放状态;重复 SYN 与满输出队列不延长半开期限;未确认 FIN 超时
唤醒读端并释放状态;正常关闭记录应答迟到包、不引出 RST、不被延长,
保留期间跳过相同 ConnId,期满后删除旧记录而不影响新连接;满输出
队列下未知可靠 FIN 不能阻止后续 SYN 进入握手。
可控时钟只用于测试期限,不修改生产时间常量。数据测试仍通过实际 KCP
发送、接收及 AsyncRead/AsyncWrite 路径。
## 真实旧依赖兼容
dev-dependency 固定并实际运行 `d7427c2``b37ee660` 两个历史实现,
没有用新代码上的 legacy 开关模拟旧端。
7 项测试覆盖旧 SYN 处理探针和两个基线的新旧双向连接、256 KiB 双向
数据、半关闭、重复 SYN。报文捕获断言:新源初始 SYN 可为 `rsv=1`
其余混合连接报文均为 `rsv=0`;没有 FIN_ACK、新增 RST 或重复 accept。
`d7427c2` 原有正常关闭被报告为 BrokenPipe 的行为仍在对照中保留。
兼容意味着旧节点仍可互通,不意味着它自动获得新协议的恢复能力。
## 实际代理流量
实验使用独立 namespace,底层 UDP
覆盖 TCP/KCP/QUIC 与内核/smoltcp 六种模式。固定每轮 300 次短连接、
16 并发、5 s socket 超时,逐字节校验大小请求、纯空请求及反向半关闭。
验证分为两个阶段;不将初版流量统计冒充最终版本结果。
### 初版 c84733d
二进制 `protocol-easytier-core` 的 SHA-256 为
`f41ff98762ed1ce8691d8f83b8c9e47acc4548c76432a17cd2daba36e2cc7b80`
- 六模式主矩阵:1,800 次短连接、12 次大小请求半关闭、6 次反向
半关闭、192 次纯空请求,全部通过。
- KCP 每栈追加三轮,合并主矩阵共 2,400 次 KCP 短连接,全部通过。
- `netem delay 10ms 3ms loss 1%` 六模式:1,800 次短连接、12 次大小
半关闭、6 次反向、192 次纯空及每组合 15 s 双连接持续流量,全部通过。
- 新旧双方向各六模式:3,600 次短连接、24 次大小半关闭、384 次纯空
和每组合 3 s 持续流量通过;**反向半关闭为 11/12,存在一次失败**。
旧端为依赖 b37 的 `delivery-easytier-core`SHA-256 为
`4635810ac9a536258f9b7ff606e5e1a5eb0243cb0f187825ba6f721d92dfc4fe`
失败发生在新 source → 旧 destination 的 KCP/smoltcp,连接
`conv=3560055726`、源端口 `40748`:客户端已经收到完整 1 MiB,随后
等待 EOF 超过 5 s;因此未进入后发 256 KiB 阶段。旧目标端在
10:11:08.953 进入 LocalClosed,直到客户端超时才见客户端方向关闭。
DEBUG 不能确定旧端 FIN 后续的发送、到达或处理点,不能直接定性为
FIN 丢失,也不能把混合版本测试写成全部通过。
另一次有界 KCP/kernel 丢包 TRACE 测试中,300 次短连接及全部半关闭
通过。实际捕获的控制事件全部使用 version 1:连接 `1468864199`
重复 SYNACK 相隔约 200 ms,目标随后收到 ACK,业务完成;连接
`1468864161` 的重复 FIN 后收到 FIN_ACK。它们证明实际链路使用了
新控制恢复路径;单轮成功不代表任意丢包条件下都能成功。
### 最终 3ef5c416
- 六模式主矩阵全部通过:1,800 次短连接、12 次大小半关闭、6 次反向
半关闭、192 次纯空请求。
- 六模式随机丢包全部通过:相同流量规模,另每组合 15 s 双连接持续
逐字节回显。原 5 s socket 超时不变,没有添加应用重试。
- 混合版本无丢包,两连接方向 × 两个 KCP 栈全部通过:1,200 次短连接、
8 次大小半关闭、4 次反向及 128 次纯空请求。该轮通过不覆盖初版
混合实验中的 EOF 超时记录。
- KCP 每栈追加三轮全部通过;合并主矩阵为 **2,400/2,400** 次 KCP
短连接;追加轮次的 12 次大小、6 次反向和 192 次纯空也全部通过。
混合丢包仍走 legacy,下表为单轮**失败数**,不重跑取最好结果。
每组合为 300 短连接、2 次大小半关闭、1 次反向、32 次纯空和 3 s 持续流量。
| 连接方向/栈 | 短连接失败 | 大小失败 | 反向失败 | 纯空失败 | 持续失败 |
| --- | --- | --- | --- | --- | --- |
| b37 → 最终新 / kernel | 6 | 0 | 0 | 16 | 0 |
| b37 → 最终新 / smoltcp | 2 | 0 | 0 | 13 | 0 |
| 最终新 → b37 / kernel | 7 | 1 | 0 | 16 | 0 |
| 最终新 → b37 / smoltcp | 1 | 0 | 0 | 14 | 0 |
唯一大小用例失败停在 greeting 阶段,5 s 超时;纯空失败为 response
阶段收到空 EOF。为判断兼容回退,另跑一次相同条件、相同业务参数的
b37 → b37 旧旧对照:kernel 短连接失败 2/300、纯空失败 12/32
smoltcp 分别为 0/300、16/32。两栈的大小、反向与持续项目均通过。
旧旧 TRACE 确认了一条失败链:kernel 的 `conv=3046346801`、空请求
`id=3`、源端口 `35958`,目标在 10:30:44.112 先收到 FIN,直接
Closed 并发送 RST10:30:44.115 才收到最终 ACK|DATA。源在 .113
收到 RST,客户端约 37 ms 后得到零字节 EOF。`src/state.rs`
SynReceived + FIN → Closed/RST 路径本次没有修改;可靠模式单独支持
FIN 完成握手,legacy 按已批准设计保留原行为。
这条时序证明上述失效路径在旧旧连接也存在,不能由随机样本的失败率
断言所有混合版本失败均已归因或已经排除一切回退。混合实验原 DEBUG
不足以逐包归因全部失败。兼容保证旧节点可按原协议互通;控制恢复能力
需要两端都协商为 version 1,混合部署仍不能获得这一保证。
最终丢包轮次直接启用 KCP TRACE:kernel 捕获 5 个成功连接收到重复
SYNACK、4 个收到重复 FINsmoltcp 分别为 2 个、5 个。捕获的控制
报文均使用 version 1。详见 `final-control-recovery-*-summary.json`
及对应 `evidence.log`;这份证据直接绑定最终二进制。
资源观察区分应用代理条目与内部 KCP 状态。CLI 不直接暴露依赖内部
关闭记录,不能用 CLI 条目清零证明内部记录已经删除;内部期限由
生命周期测试验证。实际流量保存起始、结束、15 s、80 s 的 FD/RSS 与
应用代理条目。65 s 关闭记录属于设计成本,不能沿用旧的 15 s 内部
状态必须清零的断言。
最终每栈三轮连续负载的资源结果如下。a 为 sourceb 为 destination。
该追加轮次的 FD 全程未增长;应用 proxy 条目在 15 s 与 80 s 均为零。
| 栈/节点 | FD 前后 | RSS 前 → 80 sKiB | 负载 CPU 秒 / 墙钟秒 | 静置 1580 s 单核 CPU |
| --- | --- | --- | --- | --- |
| kernel/a | 15 → 15 | 46,944 → 50,792 | 2.20 / 2.697 | 0.29% |
| kernel/b | 15 → 15 | 46,580 → 49,344 | 1.12 / 2.694 | 0.31% |
| smoltcp/a | 14 → 14 | 46,784 → 50,352 | 4.39 / 5.378 | 0.49% |
| smoltcp/b | 14 → 14 | 46,024 → 49,060 | 2.32 / 5.368 | 0.48% |
CPU 来自 `/proc/PID/stat` 的 user/system ticks 与单调时钟差,包含
路由、心跳、代理、日志等全部进程工作。产物为 debug 构建,表中负载
CPU 也不是纯协议开销或吞吐基准。RSS 未回到起点,无法由这些采样区分
分配器缓存与其他长期对象,也不能推算每条关闭记录的精确内存成本。
内部对象期限由单测验证,生产规模的逐对象内存与长期容量验证仍未完成。
主矩阵 kernel/b 在 15 s 采样曾由 15 个 FD 暂升为 16,80 s 恢复为 15
该瞬时变化同样保留在原始报告,未作为持续增长处理。
原始记录为 `final-kcp-repeat-kcp-*-resources.json`,换算另存
`final-resource-summary.json`
## 审查、复现与限制
初轮独立子代理只读审查 `b37ee660..c84733d`,没有高置信度缺陷发现。
最终由新子代理审查完整 `b37ee660..3ef5c416` 及 EasyTier 最终 pin
未发现 blocker / major;重点检查旧版输出规则、握手与关闭交叉状态、
队列、取消清理和锁序。
最终审查记录一项 **minor / high confidence** 待办:
`kcp-sys/src/endpoint.rs:922` 新 SYN 路径先 notify 再插入连接状态,
多线程时可能先消费通知并漏过首次 SYNACK 调度。正常源端约 200 ms 后
重发 SYN 即可恢复;若后续 SYN 未到达,则等待约 10 s 周期扫描。状态
不会丢失,也不会永久阻塞,但特别短的 connect 期限可能超时。按用户
minor 默认记录的规则保留;后续最小改动是将通知放到状态插入之后。
主要命令:
```sh
# kcp-sys 工作树
cargo test --all-targets
cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
# EasyTierLinux root 集成测试在 rust 容器内执行
cargo +1.95 test --locked -p easytier-core \
--features proxy-smoltcp-stack --lib gateway::
cargo +1.95 nextest run --locked -p easytier --features full --lib \
-E 'test(subnet_proxy_three_node_test) | test(subnet_proxy_half_close_test) | test(acl_rule_test_inbound) | test(acl_rule_test_subnet_proxy) | test(proxy_three_node_disconnect_test) | test(config_patch_test) | test(port_forward_with_inbound_default_drop_acl_test)' \
--test-threads 1 --no-fail-fast
cargo +1.95 clippy --locked -p easytier --features full \
--lib --tests -- -D warnings
```
本机原始日志、脚本、JSON 与二进制:
```text
/data/project/kcp-control-validation-20260914/
```
主要证据为 `final-ack-red.log``final-ack-green.log`
`final-dependency-tests.log``final-dependency-clippy.log`
`unknown-close-full-output-red.log``legacy-final.log`
`negotiation-lifetime-regressions.log``final-native-*-tests.log`
`linux-gateway.log``integration-final.log``app-final-clippy.log`
`integration-protocol.log` 是初版的中断轮次,切换最终实现后重新执行完整
矩阵,不计入最终通过数。
流量的准确 argv、产物 hash 和逐连接结果另存该目录。完整流量汇总为
`traffic-summary.md`,混合丢包对照为 `mixed-loss-analysis.md`
旧旧逐包证据为 `legacy-fin-before-ack-evidence.log`
`legacy-fin-before-ack-client.json`。测试环境清理记录为 `cleanup.json`
此前单次反向超时的具体丢包点仍不能由旧 DEBUG 日志倒推出。定点 FIN
丢失测试证明该类失效现在能够恢复,不能因此改写原事故的根因结论。
Windows/macOS 原生 TUN 端到端、生产规模长期负载、吞吐与容量上限仍
不是这些有限测试能够证明的事项。
+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 内存,不使用未限速吞吐结果推断性能,
避免吞吐差异污染内存结论。
+84
View File
@@ -0,0 +1,84 @@
# Host socket protection
VPN bypass is a socket-creation requirement, not an operation on a socket that
core has already connected. Core and WASI guests never need an OS file descriptor.
## Portable requests
`TcpBindOptions::need_protect` and `UdpBindOptions::need_protect` travel through the
existing connect/bind operations. A host requiring VPN bypass must acknowledge
successful protection before connect, bind/listen, or publishing the socket.
Failure or cancellation fails creation and discards the owned socket; emitting
an event alone is not acknowledgement. Platforms without VPN bypass can treat
the requirement as a no-op.
- TCP connect constructors request protection, including egress for proxies.
- TCP transport/hole-punch listeners request protection. Hosts retain this flag
on the listener and protect accepted children before handing them to core.
- Local ProxyNat/SOCKS/port-forward/port-lease listeners do not request it.
- UDP transport, candidates, listeners, NAT egress and STUN request protection;
HolePunchControl, local SOCKS/port-forward and port-lease sockets do not.
- Low-level TCP/UDP bind options default to protection, including deserialization
of options without `need_protect`. The named local/TUN-facing constructors set
`false` explicitly. The UDP default keeps its historical purpose label for
socket setup; only the named `hole_punch_control()` constructor opts out.
- `with_bind()` replaces the **entire** bind object. A local listener's replacement
must retain its opt-out rather than inherit the protected default. A native
adapter must honor explicit `false`, not silently change it based on `purpose`.
- DNS and source-route queries are already host-owned operations. A bypass-enabled
host must protect their underlying sockets before querying/probing, including
DNS TCP fallback, rather than letting system DNS silently bypass this contract.
## TUN-facing ingress and port forwarding
The extra `force_smoltcp` wildcard port-forward ingress listener has been removed.
Normal TUN-backed ingress is delivered to the existing unprotected native listener;
its accepted sockets remain unprotected so overlay replies can return through TUN.
The physical/underlay egress socket is protected independently. A separate
DataPlane listener must not mask broken host/TUN routing in this path.
This does not remove the existing public DataPlane listener APIs, the generic
no-TUN smoltcp TCP proxy, or `force_smoltcp` itself. Those have other uses. Whether
Android subnet proxy works without forced smoltcp needs actual platform regression
testing; socket-creation unit tests alone do not establish that result.
## Native integration and HarmonyOS
The native `easytier` adapter implements the creation requirement using an async
`NativeSocketProtector` callback. This is a native implementation detail, not a
new portable or WASI ABI. It takes only the native handle; the creation options
select policy, without a second purpose enum. Namespace switching is confined to synchronous socket
creation and never held across the callback's await.
The existing native `bind` builder is async and shared by TCP/UDP creation. Its
legacy direct-call default remains unprotected; portable factories explicitly
pass their core bind options (default protected). Callers must await `.call()`.
TCP listeners reuse TCP socket creation then listen, instead of duplicating the
setup. Existing legacy WebSocket direct-call policy is unchanged.
The HarmonyOS broker wakes the already-pending request consumer with `Notify`
(no polling timer). It keeps a duplicate FD alive until ArkTS completes
`VpnConnection.protect(fd)` and returns its ACK. The waiting creation future is
woken immediately by the oneshot acknowledgement. Failure stays fail-closed;
shutdown retains dispatched FDs for late ACKs to prevent FD reuse races.
The ArkTS request shape is unchanged; its diagnostic `purpose` string is now
the generic `"socket"`. Neither ACK routing nor protection policy uses that label.
This guarantees ordering, not a wall-clock real-time bound: OS/ArkTS scheduling
can still delay protection. Such a delay keeps the socket unconnected; it must
never allow the first SYN/query to race ahead of protection.
## WASI option format
The `easytier_host` import names and function signatures are unchanged. TCP
connect, TCP listen and UDP bind use option document **version 3** (previously 2):
one `u8` boolean `need_protect` is inserted immediately after the existing purpose
byte and before the optional bind-device field. All other field encodings and
purpose values are unchanged. The document version lets older hosts reject
unsupported options instead of silently ignoring protection. DNS, environment,
instance-config and data-plane layouts/versions are unchanged.
An embedding host must update its versioned option decoder and honor the flag
inside its existing creation implementation. The external host implementation
is not in this repository: building the guest proves propagation/compatibility
of imports, not that every external host has implemented platform protection.
@@ -0,0 +1,137 @@
# TCP proxy flow-key 验证记录(2026-09-13
本次验证没有发现只在修复版本出现的行为退化。同源端口、不同目标的
并发连接在六种代理模式下均由父提交的超时变为成功。验证中仍有半关闭
失败和 KCP 突发短连接超时,父提交也存在这些现象,不能将结果描述为
所有场景均无异常。
## 版本与范围
- 修复版本:`eb83655958be932d6de34e090dc361b6f3ba3393`
- 父提交:`e0bdb516b6dc8a654940efbe12960dbfa846f424`
- 本次提交仅新增测试与记录,生产代码保持上述修复版本的内容。
- Linux 测试在现有 `rust` 容器内运行,Rust 1.93.1。
- macOS arm64、Windows x64 原生测试使用 Rust 1.95.0。
父提交使用独立 worktree,但复用当前 worktree 的 `target`。第一次父提交
网关测试意外复用了修复版本的构建缓存,结果已排除。清理相应 package
的构建产物后重新编译,确认父提交网关测试为 189 项,修复版本原有
192 项。真实流量实验还通过 CLI 核对两端实际运行的版本号。
## 自动化验证
| 验证 | 结果 |
| --- | --- |
| 修复版本完整 `subnet_proxy_three_node_test` 矩阵 | 256/256714.800 秒 |
| ACL、端口转发 ACL、配置更新、代理断连 | 父提交与修复版本各 14/14 |
| 父提交 Linux 网关测试 | 189/189 |
| 新增测试后的 Linux 网关测试 | 198/198 |
| 新增测试后的 macOS 原生网关测试 | 198/198 |
| 新增测试后的 Windows 原生网关测试 | 198/198 |
完整三节点矩阵覆盖 TUN/no-TUN、普通/公共中继、源端 KCP/QUIC 开关、
目标端 KCP/QUIC 开关及对应输入禁用组合。每个组合检查映射子网地址、
真实子网地址和节点虚拟地址上的 ICMP、TCP、UDP。
命令(Linux 需在容器内运行):
```sh
cargo nextest run -p easytier --features full --lib \
subnet_proxy_three_node_test --test-threads 1 --no-fail-fast
cargo test -p easytier-core --features proxy-smoltcp-stack --lib gateway::
cargo fmt --all -- --check
cargo clippy -p easytier-core \
--features proxy-smoltcp-stack,ring-crypto --lib --tests -- -D warnings
```
格式检查和上述严格 Clippy 检查均通过。仅启用 `proxy-smoltcp-stack`
时,严格 Clippy 被未修改的 `tunnel/encrypt/mod.rs`
`assert_interoperable` 未使用警告阻断;增加 `ring-crypto` 会启用调用
该函数的现有后端互操作测试。本次没有修改或屏蔽该警告。
14 项补充集成测试来自以下测试组,使用各版本独立保存的测试二进制,
逐项 `--exact` 执行,避免不同进程同时操作相同的测试 network namespace
- `acl_rule_test_inbound`4 项。
- `acl_rule_test_subnet_proxy`4 项。
- `port_forward_with_inbound_default_drop_acl_test`3 项。
- `config_patch_test`1 项。
- `proxy_three_node_disconnect_test`2 项。
三节点矩阵与流量实验在新增测试前完成;新增测试不改变生产代码,随后
在三平台重新执行完整网关测试。macOS、Windows 的结果不包含原生 TUN
端到端验证。
## 新增回归测试
测试位于 `easytier-core/src/gateway/proxy/tcp_proxy_engine.rs`
1. SYN 在 accept 前后重传,均保留转换端口,且不会重复 accept。
2. 旧连接处于 ClosingSrc、ClosingDst、Closed 时,被同一流的新连接
替换;旧连接清理不会删除新映射,反向地址、端口和校验和仍正确。
3. 过期 SYN 同时释放两个索引,已 accept 的连接不受 SYN 超时清理影响。
4. 转换端口计数器回绕后跳过零、已占用端口和监听端口。
5. 实际填满同一源 IP 的 65,534 个转换端口后,新 SYN 被丢弃;其他源
IP 仍可建立映射,释放一个条目后该端口可复用,clear 后也可重新分配。
6. 八个线程同时处理同一流,仅建立一个映射并成功 accept 一次。
端口池测试验证实际容量和恢复行为,没有将 debug 构建的耗时用作生产
性能阈值,也没有通过延长超时或添加重试改变被测行为。
## 真实流量对照
使用独立的 `flow_val_a``flow_val_b` namespace,物理链路为一对 veth。
虚拟地址为 `10.251.92.1/24``10.251.92.2/24`;目标端将
`192.0.2.0/24` 映射到 `198.18.0.0/24`。两个 TCP 服务监听真实地址
`.10``.11` 的 23456 端口,返回服务地址标记,并逐字节回显、校验数据。
底层隧道使用 UDP,两个节点均使用四线程运行时。
每个版本分别验证普通 TCP、KCP、QUIC 与内核/smoltcp 的六种组合。
| 场景 | 父提交 | 修复版本 |
| --- | --- | --- |
| 源 IP/端口相同、两个不同目标的并发连接,各进行 30 轮回显 | 6/6 超时 | 6/6 通过 |
| 同一四元组 RST 后重连,30 次,间隔 20ms | 6/6 通过 | 6/6 通过 |
| 普通 TCP、QUIC 的短连接,各 300 次、16 并发 | 各组合 300/300 | 各组合 300/300 |
| KCP 内核模式短连接,300 次、16 并发 | 4 次超时 | 4 次超时 |
| KCP smoltcp 模式短连接,300 次、16 并发 | 12 次超时 | 11 次超时 |
| 关闭客户端写端后等待服务端响应 | 6/6 无响应数据 | 6/6 无响应数据 |
各组合完成流量后等待 15 秒。修复版本两端的代理条目均回收为零,FD
数量回到接近起始水平(采样期间 RPC 连接带来约一个 FD 的差异)。这只能
证明本次有限负载下的回收行为,不能证明长期内存占用没有增长。
KCP 的超时阈值为 5 秒,尚未定位这些突发短连接超时的根因;两版都有
失败不等于已证明每次失败属于同一根因。半关闭实验的服务端在 EOF 后
返回响应;未修改的 `copy_bidirectional_no_shutdown` 在任一方向结束
后即退出转发,与两版都丢失该响应的现象一致。本次不修复这两类问题。
## 丢包与混合版本
- 在客户端 veth 出方向施加 `netem delay 10ms 3ms loss 1%`
- 两版各六种模式,每个组合保持两个连接持续回显至少 15 秒,所有数据
均通过逐字节校验;共 12/12 通过。
- 无 netem 时,旧源端/新目标端、新源端/旧目标端,各覆盖六种模式。
两个连接持续回显至少 2 秒,共 12/12 通过。
- 混合版本验证使用不同客户端源端口,验证正常互通;它不意味着仍运行
旧代理引擎的一端也获得了同源端口冲突修复。
## 证据与限制
本机原始日志、流量脚本、各阶段 JSON、二进制与 SHA-256 清单保存在:
```text
/data/project/tcp-flow-validation-20260913/
```
主要记录为 `manifest.json``current-matrix-summary.log`
`*-extra.json``traffic-matrix.json``loss-interop.json`
`current-final*gateway.log`。矩阵记录是最终摘要,不是完整逐项日志。
实验进程、独立 namespace 和 netem 均已清理。
未覆盖 Windows/macOS 原生 TUN 路径、生产防火墙/conntrack 规则兼容性、
长期运行、吞吐回归基准,以及接近容量极限时的真实内核连接负载。
端口耗尽已在引擎级验证,但不能替代上述生产容量测试。
@@ -0,0 +1,178 @@
# TCP 代理半关闭与 KCP 短连接修复验证(2026-09-14
本次修复上一轮验证发现的半关闭失败、无丢包 KCP 突发连接交接丢失,
以及跨平台验证暴露的握手前心跳竞态。这些是转发与连接生命周期的局部
逻辑问题;没有改变 KCP 报文格式,也没有增加重试或放宽业务超时。
丢包网络下另有握手最终 ACK 丢失的问题,本次未修复,见下文。
## 版本与根因
- EasyTier 基线:`24572b49`;生产修复:`03fa375e``fd6e2a3e`
- kcp-sys 基线:`d7427c22d764deb1860a7d37acc446ed5033464c`
- 最终依赖:`b37ee660fb70bb6d816fb8bbc08b140e55e7218b`
`Cargo.toml``Cargo.lock` 均锁定此 Git revision。
已发布到 kcp-sys 的 `fix/accept-half-close-20260914` 专用分支,
便于其他机器获取锁定的依赖;未合并主分支。
- 旧版真实流量对照:`eb836559`,已包含 flow-key 修复,尚无本次修复。
- 最终流量二进制:`delivery-easytier-core`SHA-256
`4635810ac9a536258f9b7ff606e5e1a5eb0243cb0f187825ba6f721d92dfc4fe`
构建内容与最终生产代码一致;构建发生在依赖 pin 提交前,内嵌版本号
不用于区分本次产物,以 SHA-256 和依赖 revision 为准。
修复内容:
1. 原转发函数在任一方向 EOF 后退出,取消另一方向,服务端在请求 EOF
后返回的响应因此丢失。改用 Tokio 双向拷贝传播写端关闭,继续转发
反向数据,直到双向完成或发生错误。
2. KCP 容量为 4 的 accept 通知队列满时,已建立连接失去交接机会。
在连接状态中保留待 accept 标记,队列为空时领取待交接连接;保留
原有有界通知队列和串行领取,防止重复交接。
3. KCP 将正常关闭当作读错误,并可能在 FIN 后丢弃已经确认接收、尚未
交给应用的数据。现在先排空接收数据再返回 EOF,RST 和端点销毁仍
返回错误;双向关闭清理等待接收任务完成。EOF 判断和过期清理的
状态读取顺序也一并修正,避免并发数据到达或心跳更新被旧判断覆盖。
4. FIN 可以先于 accept 或 connect 返回到达。创建流时继承该关闭状态,
使纯空请求、纯空响应也能得到 EOF。半关闭连接继续响应心跳。
5. Windows 原生测试捕获到 PING 先于 SYN 到达,对端因连接未知返回
RST;旧依赖也复现相同时序。周期心跳现在只覆盖已建立和半关闭状态。
## 回归覆盖与自动化结果
| 验证 | 结果 |
| --- | --- |
| Linux、macOS arm64、Windows x64 原生网关测试 | 各 200/200 |
| 最终 kcp-sys 三平台原生测试 | 各 10/10,两个 example target 通过 |
| 最终完整三节点矩阵及补充集成测试 | 276/276848.628 秒 |
| EasyTier 格式检查、Linux 严格 Clippy | 通过 |
| kcp-sys Linux/macOS 格式检查、严格 Clippy | 通过 |
网关新增两个测试,分别从两端先关闭写方向;使用空请求、32 KiB 请求
及 64 KiB 响应,接收方等到 EOF 才返回响应。旧转发函数两项均在 5 秒
超时,修复后通过。原生网关测试不依赖应用层 KCP,心跳修复没有改变
这些测试的生产代码。
新增三节点半关闭测试覆盖 TCP/KCP/QUIC × 内核/smoltcp 六种模式,
各检查两个关闭方向、空请求和 64 KiB 请求、128 KiB 响应。完整集成
集合共 276 项:原有三节点矩阵 256 项、新增 6 项、ACL/配置更新/
端口转发/断连 14 项,均在已有 `rust` 容器中串行运行。
依赖回归覆盖:32 次握手全部完成后才 accept;200 KiB 缓冲数据后的
EOF/响应;双向关闭后跨越清理周期再读数据;半关闭心跳;RST/端点销毁
读错误;FIN 先于 accept、FIN 先于 connect 返回;各握手阶段的心跳
筛选。通知饱和测试在旧实现第五次 accept 超时;提前 FIN 测试在修复
前等待 EOF 超时。排空测试使用多工作线程运行时,并做过额外重复验证。
主要命令(Linux 容器内,Rust 1.95):
```sh
cargo +1.95 test --locked -p easytier-core \
--features proxy-smoltcp-stack --lib gateway::
cargo +1.95 nextest run --locked -p easytier --features full --lib \
-E 'test(subnet_proxy_three_node_test) | test(subnet_proxy_half_close_test) | test(acl_rule_test_inbound) | test(acl_rule_test_subnet_proxy) | test(proxy_three_node_disconnect_test) | test(config_patch_test) | test(port_forward_with_inbound_default_drop_acl_test)' \
--test-threads 1 --no-fail-fast
cargo fmt --all -- --check
cargo +1.95 clippy --locked -p easytier --features full \
--lib --tests -- -D warnings
```
网关严格 Clippy 另以 `proxy-smoltcp-stack,ring-crypto` 通过;原因见上一轮
记录中的既有未使用警告说明。Windows 所选 stable 工具链缺少格式检查
组件,未将 Windows 格式或 Clippy 计为通过。
## 最终二进制真实流量
普通 TCP、KCP、QUIC 与内核/smoltcp 六种组合,底层 UDP;每组合 300 次
短连接、16 并发、5 秒 socket 超时,检查 greeting 和逐字节回显。
- 六模式主矩阵:1,800/1,800 次短连接通过。
- 请求 EOF 后返回响应:小/大请求共 12/12,通过;大请求 256 KiB
响应 1 MiB + 4 字节。
- 服务端先关闭写端,客户端读到 EOF 后再发 256 KiB6/6,通过,
服务端实际校验接收数据。
- connect 后立即关闭写端,不发送模式字节、不等待 greeting:每模式
32 次、16 并发,共 192/192 次纯空请求通过。
- KCP 每栈额外三轮,与主矩阵合计 2,400/2,400 次短连接通过。追加
测试的大小半关闭及 192 次纯空请求通过,6 次反向半关闭中 1 次超时,
因此不能将所有追加半关闭计为通过,具体证据见下文。
- 最终版本在 `delay 10ms 3ms loss 1%` 下,六模式各两个连接持续
15 秒逐字节回显、12 次大小半关闭及 6 次反向半关闭通过。KCP 短连接
仍有内核模式 5/300、smoltcp 模式 8/300 次 greeting 超时,其他
四种模式零失败;没有为每次超时单独抓包证明原因相同。
- 新旧两方向 × 六模式,共 12 组合正常持续回显通过。旧源端→新目标端
1,800 次短连接全通过;新源端→旧目标端 KCP 内核模式 12/300、
smoltcp 模式 9/300 次超时,其他四种模式零失败。
旧版同样的无丢包 KCP 2,400 次短连接有 78 次 greeting 超时;旧版六种
模式在请求 EOF 后均没有响应,且出现过 QUIC 请求数据被截断。
最终版本 KCP 主矩阵后,两种栈源端各 335 条 Closed 代理记录均在
15 秒观察点清零;目标端记录为零。两端 FD 数保持内核模式 15、smoltcp
模式 14。RSS 较起始保留约 3.2–4.4 MiB 增长,单次有限负载不能证明
或排除长期内存泄漏,也不能将代理条目清零等同于所有内部对象回收。
## 仍然存在的丢包握手问题与升级限制
在源端施加 `netem delay 10ms 3ms loss 1%` 后,中间修复版本
SHA-256 `2e78ecf…`KCP 仍有 3/600 次 greeting 超时。额外有界
TRACE 重跑捕获 3 个连接:源端收到 SYN|ACK,发出空 ACK|DATA 后即
返回已建立;目标端未收到最终 ACK,直到 5 秒后的 FIN 才返回 RST。
这与 accept 通知饱和不同,目标端尚未完成握手。
该控制报文可靠性问题需要单独设计握手恢复、重传及过期清理的一致性,
不能仅延长超时或重试应用请求。本次没有声称消除了所有 KCP 超时。
具体连接号和双方日志行见 `remaining-loss-handshake.md`
`loss-handshake-evidence.log`
混合版本正常回显通过并不意味着旧端获得修复。旧 KCP 目标端仍保留
accept 缺陷;KCP 的完整半关闭能力需要两端升级。没有修改报文格式。
追加无 netem 的 KCP/kernel 反向半关闭中,连接 `3969305677` 在等
服务端数据和 EOF 时超时;服务端没有收到客户端原定在 EOF 后发送的
256 KiB 数据。发送端同一连接出现 780 次数据输出队列 Full,随后在
08:36:47.247 UTC 发送缓冲排空,FIN 成功进入输出队列;接收端直到
5 秒业务超时都未报告对端关闭,08:37:01.030 才报告关闭或重置信号。
该 debug 日志行也可能由 RST 触发,不能据此认定迟到的是 FIN,更不能
将数据队列 Full 直接解释为 FIN 被该队列丢弃。现有 FIN 发送路径没有
确认重传;这一单次失败的具体丢包点尚未证实,保留为未解决的验证异常。
同场景有界 TRACE 重跑 3 轮,900 次短连接、3 次反向半关闭均通过,
抓包确认成功轮次收到 FIN 并完成反向数据,未复现原失败;不能以重跑
通过覆盖原失败。旧依赖与最终依赖的发送排空后入队 FIN 路径相同,
对照保存在 `fin-send-path-old-new.txt`
异常轮次的 15 秒资源观察点仍有 1 条 Closed 代理记录,无 Connecting/
Connected,FD 已回到起始值;因此仅主矩阵可以报告该观察点全部清零。
定向 TRACE 三轮的 1,005 条 Closed 记录在 15 秒观察点全部清零。
## 验证中的异常与证据
首轮未完成的集成运行中,端口转发 ACL case 3 曾在连接本地监听端口时
出现一次 ConnectionRefused;随后完整一轮 276 项通过,同项也通过。
旧版单次及额外十次均通过,因此没有证据把这次失败断言为旧版必现问题,
也未修改无关的端口转发代码。新增半关闭测试的早期夹具曾只等待路由
公告而未等待实际 TUN 路由可用,已补足就绪检查,并用有界 try_join
及时报告连接错误;这不计入生产代码的 red/green 对照。
Windows 测试早期表现为挂起,原因是测试 join 在 connect 失败后仍等待
accept。限定握手等待后暴露出上述 PING/RST/SYN 竞态,旧依赖和修复前
分支均有 TRACE 证据;最终原生测试通过。完整 diff 和后续心跳增量均
经过独立子代理审查,没有 high-confidence 的新缺陷发现。
原始脚本、日志、逐连接 JSON、二进制和哈希保存在本机:
```text
/data/project/proxy-close-validation-20260914/
```
主要文件:`delivery-manifest.json``delivery-integration.log``delivery-clippy.log`
`final-core-gateway.log``macos-core.log``windows-core.log`
`mac-kcp-heartbeat-tests.log``final-tests.log`Windows KCP)、
`delivery-summary.json``delivery-kcp-repeat-summary.json`
`delivery-*-resources.json``traffic-summary.md``traffic-binaries.json`
`delivery-commands.json``artifacts-manifest.json``delivery-reverse-timeout.md`
`relay-red.log``windows-kcp-trace-fail.txt`
`windows-kcp-baseline-startup.txt`。Windows 部分原始日志为 UTF-16LE。
Linux kcp-sys 测试、格式、Clippy 及依赖测试 red/green 对照为子代理
执行结果,未单独落盘原始命令日志;最终 10 项测试耗时 11.03 秒。
未覆盖 Windows/macOS 原生 TUN 端到端路径、生产规模长时间运行、吞吐
基准及容量极限负载。流量实验仅使用并清理自己的 namespace 和进程。
@@ -1,16 +1,17 @@
[package] [package]
name = "easytier-android-jni" name = "easytier-android-jni"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition.workspace = true
[lib] [lib]
crate-type = ["cdylib"] crate-type = ["cdylib"]
[dependencies] [dependencies]
jni = "0.21" # JNI 0.22 changes native entry points to EnvUnowned and Env.
once_cell = "1.18.0" jni = "0.21.1"
log = "0.4" once_cell.workspace = true
android_logger = "0.13" log.workspace = true
serde = { version = "1.0", features = ["derive"] } android_logger = "0.15"
serde_json = "1.0" serde_json.workspace = true
easytier = { path = "../../easytier" } easytier = { workspace = true, default-features = true }
easytier-ffi.workspace = true
@@ -8,6 +8,7 @@
- 📱 原生 Android JNI 支持 - 📱 原生 Android JNI 支持
- 🔧 支持多种 Android 架构 (arm64-v8a, armeabi-v7a, x86, x86_64) - 🔧 支持多种 Android 架构 (arm64-v8a, armeabi-v7a, x86, x86_64)
- 🛡️ 类型安全的 Java 接口 - 🛡️ 类型安全的 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 服务集成 ### VPN 服务集成
如果您要在 Android VPN 服务中使用: 如果您要在 Android VPN 服务中使用:
@@ -264,4 +279,4 @@ public class EasyTierVpnService extends VpnService {
- [EasyTier 主项目](https://github.com/EasyTier/EasyTier) - [EasyTier 主项目](https://github.com/EasyTier/EasyTier)
- [Android NDK 文档](https://developer.android.com/ndk) - [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");
}
+65 -61
View File
@@ -2,6 +2,7 @@
# EasyTier Android JNI 构建脚本 # EasyTier Android JNI 构建脚本
# 用于编译适用于 Android 平台的 JNI 库 # 用于编译适用于 Android 平台的 JNI 库
# 使用 cargo-ndk 工具简化 Android 编译过程
set -e set -e
@@ -13,8 +14,8 @@ NC='\033[0m' # No Color
REPO_ROOT=$(git rev-parse --show-toplevel) REPO_ROOT=$(git rev-parse --show-toplevel)
echo -e "${GREEN}EasyTier Android JNI 构建脚本${NC}" echo -e "${GREEN}EasyTier Android JNI 构建脚本 (使用 cargo-ndk)${NC}"
echo "==============================" echo "=============================================="
# 检查 Rust 是否安装 # 检查 Rust 是否安装
if ! command -v rustc &> /dev/null; then if ! command -v rustc &> /dev/null; then
@@ -28,18 +29,38 @@ if ! command -v cargo &> /dev/null; then
exit 1 exit 1
fi fi
# Android 目标架构 # 检查 cargo-ndk 是否安装
# TARGETS=("aarch64-linux-android" "armv7-linux-androideabi" "i686-linux-android" "x86_64-linux-android") if ! cargo ndk --version &> /dev/null; then
TARGETS=("aarch64-linux-android") echo -e "${YELLOW}cargo-ndk 未安装,正在安装...${NC}"
cargo install cargo-ndk
if ! cargo ndk --version &> /dev/null; then
echo -e "${RED}错误: cargo-ndk 安装失败${NC}"
exit 1
fi
fi
# 检查是否安装了 Android 目标 echo -e "${GREEN}cargo-ndk 版本: $(cargo ndk --version)${NC}"
echo -e "${YELLOW}检查 Android 目标架构...${NC}"
for target in "${TARGETS[@]}"; do # Android 目标架构映射 (cargo-ndk 使用的架构名称)
if ! rustup target list --installed | grep -q "$target"; then # ANDROID_TARGETS=("arm64-v8a" "armeabi-v7a" "x86" "x86_64")
echo -e "${YELLOW}安装目标架构: $target${NC}" ANDROID_TARGETS=("arm64-v8a")
rustup target add "$target"
# Android 架构到 Rust target 的映射
declare -A TARGET_MAP
TARGET_MAP["arm64-v8a"]="aarch64-linux-android"
TARGET_MAP["armeabi-v7a"]="armv7-linux-androideabi"
TARGET_MAP["x86"]="i686-linux-android"
TARGET_MAP["x86_64"]="x86_64-linux-android"
# 检查并安装所需的 Rust target
echo -e "${YELLOW}检查并安装 Android 目标架构...${NC}"
for android_target in "${ANDROID_TARGETS[@]}"; do
rust_target="${TARGET_MAP[$android_target]}"
if ! rustup target list --installed | grep -q "$rust_target"; then
echo -e "${YELLOW}安装目标架构: $rust_target (for $android_target)${NC}"
rustup target add "$rust_target"
else else
echo -e "${GREEN}目标架构已安装: $target${NC}" echo -e "${GREEN}目标架构已安装: $rust_target (for $android_target)${NC}"
fi fi
done done
@@ -49,66 +70,46 @@ mkdir -p "$OUTPUT_DIR"
# 构建函数 # 构建函数
build_for_target() { build_for_target() {
local target=$1 local android_target=$1
echo -e "${YELLOW}构建目标: $target${NC}" echo -e "${YELLOW}构建目标: $android_target${NC}"
# 设置环境变量
export CC_aarch64_linux_android="$ANDROID_NDK_ROOT/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android21-clang"
export CC_armv7_linux_androideabi="$ANDROID_NDK_ROOT/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi21-clang"
export CC_i686_linux_android="$ANDROID_NDK_ROOT/toolchains/llvm/prebuilt/linux-x86_64/bin/i686-linux-android21-clang"
export CC_x86_64_linux_android="$ANDROID_NDK_ROOT/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linux-android21-clang"
# 首先构建 easytier-ffi # 首先构建 easytier-ffi
echo -e "${YELLOW}构建 easytier-ffi for $target${NC}" echo -e "${YELLOW}构建 easytier-ffi for $android_target${NC}"
(cd $REPO_ROOT/easytier-contrib/easytier-ffi && cargo build --target="$target" --release) (cd $REPO_ROOT/easytier-contrib/easytier-ffi && cargo ndk -t $android_target build --release)
# 设置链接器环境变量
export RUSTFLAGS="-L $(readlink -f $REPO_ROOT/target/$target/release) -l easytier_ffi"
echo $RUSTFLAGS
# 构建 JNI 库 # 构建 JNI 库
cargo build --target="$target" --release cargo ndk -t $android_target build --release
# 复制库文件到输出目录 # 复制库文件到输出目录
local arch_dir # cargo-ndk 使用 Rust target 名称作为目录名,而不是 Android 架构名称
case $target in rust_target="${TARGET_MAP[$android_target]}"
"aarch64-linux-android") mkdir -p "$OUTPUT_DIR/$android_target"
arch_dir="arm64-v8a" cp "$REPO_ROOT/target/$rust_target/release/libeasytier_android_jni.so" "$OUTPUT_DIR/$android_target/"
;; cp "$REPO_ROOT/target/$rust_target/release/libeasytier_ffi.so" "$OUTPUT_DIR/$android_target/"
"armv7-linux-androideabi") echo -e "${GREEN}库文件已复制到: $OUTPUT_DIR/$android_target/${NC}"
arch_dir="armeabi-v7a"
;;
"i686-linux-android")
arch_dir="x86"
;;
"x86_64-linux-android")
arch_dir="x86_64"
;;
esac
mkdir -p "$OUTPUT_DIR/$arch_dir"
cp "$REPO_ROOT/target/$target/release/libeasytier_android_jni.so" "$OUTPUT_DIR/$arch_dir/"
echo -e "${GREEN}库文件已复制到: $OUTPUT_DIR/$arch_dir/${NC}"
} }
# 检查 Android NDK # 检查 Android NDK (cargo-ndk 会自动处理 NDK 路径)
if [ -z "$ANDROID_NDK_ROOT" ]; then if [ -z "$ANDROID_NDK_ROOT" ] && [ -z "$ANDROID_NDK_HOME" ] && [ -z "$NDK_HOME" ]; then
echo -e "${RED}错误: 未设置 ANDROID_NDK_ROOT 环境变量${NC}" echo -e "${YELLOW}警告: 未设置 Android NDK 环境变量${NC}"
echo "请设置 ANDROID_NDK_ROOT 指向您的 Android NDK 安装目录" echo "cargo-ndk 将尝试自动检测 NDK 路径"
echo "例如: export ANDROID_NDK_ROOT=/path/to/android-ndk" echo "如果构建失败,请设置以下环境变量之一:"
exit 1 echo " - ANDROID_NDK_ROOT"
echo " - ANDROID_NDK_HOME"
echo " - NDK_HOME"
else
if [ -n "$ANDROID_NDK_ROOT" ]; then
echo -e "${GREEN}使用 Android NDK: $ANDROID_NDK_ROOT${NC}"
elif [ -n "$ANDROID_NDK_HOME" ]; then
echo -e "${GREEN}使用 Android NDK: $ANDROID_NDK_HOME${NC}"
elif [ -n "$NDK_HOME" ]; then
echo -e "${GREEN}使用 Android NDK: $NDK_HOME${NC}"
fi
fi fi
if [ ! -d "$ANDROID_NDK_ROOT" ]; then
echo -e "${RED}错误: Android NDK 目录不存在: $ANDROID_NDK_ROOT${NC}"
exit 1
fi
echo -e "${GREEN}使用 Android NDK: $ANDROID_NDK_ROOT${NC}"
# 构建所有目标 # 构建所有目标
echo -e "${YELLOW}开始构建所有目标架构...${NC}" echo -e "${YELLOW}开始构建所有目标架构...${NC}"
for target in "${TARGETS[@]}"; do for target in "${ANDROID_TARGETS[@]}"; do
build_for_target "$target" build_for_target "$target"
done done
@@ -122,4 +123,7 @@ echo ""
echo -e "${YELLOW}使用说明:${NC}" echo -e "${YELLOW}使用说明:${NC}"
echo "1. 将生成的 .so 文件复制到您的 Android 项目的 src/main/jniLibs/ 目录下" echo "1. 将生成的 .so 文件复制到您的 Android 项目的 src/main/jniLibs/ 目录下"
echo "2. 将 java/com/easytier/jni/EasyTierJNI.java 复制到您的 Android 项目中" echo "2. 将 java/com/easytier/jni/EasyTierJNI.java 复制到您的 Android 项目中"
echo "3. 在您的 Android 代码中调用 EasyTierJNI 类的方法" echo "3. 在您的 Android 代码中调用 EasyTierJNI 类的方法"
echo ""
echo -e "${GREEN}注意: 此脚本使用 cargo-ndk 工具,无需手动设置复杂的环境变量${NC}"
echo -e "${GREEN}cargo-ndk 会自动处理交叉编译所需的工具链配置${NC}"
@@ -0,0 +1,6 @@
{
global:
Java_com_easytier_jni_EasyTierJNI_*;
local:
*;
};
@@ -1,8 +1,11 @@
package com.easytier.jni package com.easytier.jni
/** EasyTier JNI 接口类 提供 Android 应用调用 EasyTier 网络功能的接口 */ fun interface ConfigServerEventCallback {
object EasyTierJNI { fun onEvent(eventJson: String)
}
/** EasyTier JNI 接口类 提供 Android 应用调用 EasyTier 核心网络功能的接口 */
object EasyTierJNI {
init { init {
// 加载本地库 // 加载本地库
System.loadLibrary("easytier_android_jni") System.loadLibrary("easytier_android_jni")
@@ -33,6 +36,35 @@ object EasyTierJNI {
*/ */
@JvmStatic external fun runNetworkInstance(config: String): Int @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 或空数组将停止所有实例 * @param instanceNames 要保留的实例名称数组,传入 null 或空数组将停止所有实例
@@ -41,14 +73,59 @@ object EasyTierJNI {
*/ */
@JvmStatic external fun retainNetworkInstance(instanceNames: Array<String>?): Int @JvmStatic external fun retainNetworkInstance(instanceNames: Array<String>?): Int
/**
* 停止指定的网络实例,其他实例不受影响
* @param instanceName 要停止的实例名称,不存在时为 no-op
* @return 0 表示成功,-1 表示失败
* @throws RuntimeException 当操作失败时抛出异常
*/
@JvmStatic external fun deleteNetworkInstance(instanceName: String): Int
/** /**
* 收集网络信息 * 收集网络信息
* @param maxLength 最大返回条目数 * @param maxLength 最大返回条目数
* @return 包含网络信息的字符串数组,每个元素格式为 "key=value" * @return 包含网络信息的 JSON 字符串
* @throws RuntimeException 当操作失败时抛出异常 * @throws RuntimeException 当操作失败时抛出异常
*/ */
@JvmStatic external fun collectNetworkInfos(maxLength: Int): String? @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 * @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()
}
}
}
+240 -288
View File
@@ -1,319 +1,271 @@
use easytier::proto::web::{NetworkInstanceRunningInfo, NetworkInstanceRunningInfoMap}; //! JNI facade for Android callers of EasyTier.
use jni::objects::{JClass, JObjectArray, JString}; //!
use jni::sys::{jint, jstring}; //! 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.
//! - `deleteNetworkInstance(instanceName)`: stop exactly one named instance.
//! - `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::JNIEnv;
use once_cell::sync::Lazy; use jni::objects::{JClass, JObject, JObjectArray, JString};
use std::ffi::{CStr, CString}; use jni::sys::{jboolean, jint, jstring};
use std::ptr;
// 定义 KeyValuePair 结构体 /// Attach a TUN file descriptor to an EasyTier network instance.
#[repr(C)] ///
#[derive(Clone, Copy)] /// Java signature:
pub struct KeyValuePair { /// `EasyTierJNI.setTunFd(instanceName: String, fd: Int): Int`
pub key: *const std::ffi::c_char, ///
pub value: *const std::ffi::c_char, /// `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
// 声明外部 C 函数 /// one is available.
extern "C" { #[unsafe(no_mangle)]
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 文件描述符
#[no_mangle]
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_setTunFd( pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_setTunFd(
mut env: JNIEnv, env: JNIEnv,
_class: JClass, class: JClass,
inst_name: JString, inst_name: JString,
fd: jint, fd: jint,
) -> jint { ) -> jint {
Lazy::force(&LOGGER_INIT); logger::init();
network_api::set_tun_fd_jni(env, class, inst_name, fd)
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 {
if let Some(error) = get_last_error() {
throw_exception(&mut env, &error);
}
}
result
}
} }
/// 解析配置 /// Validate a TOML network config string.
#[no_mangle] ///
/// 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( pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_parseConfig(
mut env: JNIEnv, env: JNIEnv,
_class: JClass, class: JClass,
config: JString, config: JString,
) -> jint { ) -> jint {
Lazy::force(&LOGGER_INIT); logger::init();
network_api::parse_config_jni(env, class, config)
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 {
if let Some(error) = get_last_error() {
throw_exception(&mut env, &error);
}
}
result
}
} }
/// 运行网络实例 /// Start one local EasyTier network instance from TOML config text.
#[no_mangle] ///
/// 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( pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_runNetworkInstance(
mut env: JNIEnv, env: JNIEnv,
_class: JClass, class: JClass,
config: JString, config: JString,
) -> jint { ) -> jint {
Lazy::force(&LOGGER_INIT); logger::init();
network_api::run_network_instance_jni(env, class, config)
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 {
if let Some(error) = get_last_error() {
throw_exception(&mut env, &error);
}
}
result
}
} }
/// 保持网络实例 /// Retain the named network instances and stop all other instances.
#[no_mangle] ///
/// 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( pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_retainNetworkInstance(
mut env: JNIEnv, env: JNIEnv,
_class: JClass, class: JClass,
instance_names: JObjectArray, instance_names: JObjectArray,
) -> jint { ) -> jint {
Lazy::force(&LOGGER_INIT); logger::init();
network_api::retain_network_instance_jni(env, class, instance_names)
// 处理 null 数组的情况
if instance_names.is_null() {
unsafe {
let result = retain_network_instance(ptr::null(), 0);
if result != 0 {
if 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 {
if 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 {
if let Some(error) = get_last_error() {
throw_exception(&mut env, &error);
}
}
result
}
} }
/// 收集网络信息 /// Stop exactly one named network instance without affecting other instances.
#[no_mangle] ///
/// Java signature:
/// `EasyTierJNI.deleteNetworkInstance(instanceName: String): Int`
///
/// An unknown name is a no-op. On failure this returns `-1` and throws
/// `RuntimeException`.
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_deleteNetworkInstance(
env: JNIEnv,
class: JClass,
instance_name: JString,
) -> jint {
logger::init();
network_api::delete_network_instance_jni(env, class, instance_name)
}
/// 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( pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_collectNetworkInfos(
mut env: JNIEnv, env: JNIEnv,
_class: JClass, class: JClass,
max_length: jint,
) -> jstring { ) -> jstring {
Lazy::force(&LOGGER_INIT); logger::init();
network_api::collect_network_infos_jni(env, class, max_length)
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()
}
}
}
} }
/// 获取最后的错误信息 /// List running network instance names and IDs.
#[no_mangle] ///
/// 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( pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_getLastError(
env: JNIEnv, env: JNIEnv,
_class: JClass, class: JClass,
) -> jstring { ) -> jstring {
match get_last_error() { error::get_last_error_jni(env, class)
Some(error) => match env.new_string(&error) { }
Ok(jstr) => jstr.into_raw(),
Err(_) => ptr::null_mut(), /// Start the managed config-server client.
}, ///
None => ptr::null_mut(), /// 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,285 @@
use std::{ffi::CStr, ptr};
use easytier::proto::api::manage::{NetworkInstanceRunningInfo, NetworkInstanceRunningInfoMap};
use easytier_ffi::{
KeyValuePair, collect_network_infos, delete_network_instance, 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 delete_network_instance_jni(
mut env: JNIEnv,
_class: JClass,
instance_name: JString,
) -> jint {
let instance_name = match jstring_to_cstring(&mut env, &instance_name) {
Ok(name) => name,
Err(error) => {
throw_exception(&mut env, &format!("Invalid instance name: {error}"));
return -1;
}
};
let instance_names = [instance_name.as_ptr()];
unsafe {
let result = delete_network_instance(instance_names.as_ptr(), instance_names.len());
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)
}
+29 -8
View File
@@ -1,17 +1,38 @@
[package] [package]
name = "easytier-ffi" name = "easytier-ffi"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition.workspace = true
[lib] [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",
]
[dependencies] [dependencies]
easytier = { path = "../../easytier" } easytier = { workspace = true, default-features = true, features = ["tracing-log"] }
easytier-core = { workspace = true, default-features = true }
once_cell = "1.18.0" once_cell.workspace = true
dashmap = "6.0" tokio = { workspace = true, default-features = true, features = ["rt-multi-thread", "io-util", "time", "sync", "macros"] }
async-trait.workspace = true
log.workspace = true
serde = { version = "1.0", features = ["derive"] } serde_json.workspace = true
serde_json = "1" uuid.workspace = true
uuid = "1.17.0"
[build-dependencies]
thunk-rs = { workspace = true, features = [
"win7",
] }
[package.metadata.cargo-machete]
ignored = [
# Windows-only build.rs setup calls thunk::thunk().
"thunk-rs",
]
@@ -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,307 @@
use std::ffi::{CString, c_char, c_int};
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,
};
/// # 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("instance not found");
return -1;
}
Err(error) => {
set_error_msg(&error.to_string());
return -1;
}
};
match ffi_context().manager.attach_tun_fd(inst_id, fd) {
Ok(_) => 0,
Err(_) => -1,
}
}
/// # Safety
/// Parse the config
pub(crate) unsafe fn parse_config(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int {
let cfg_str = unsafe {
assert!(!cfg_str.is_null());
std::ffi::CStr::from_ptr(cfg_str)
.to_string_lossy()
.into_owned()
};
if let Err(e) = TomlConfigLoader::new_from_str(&cfg_str) {
set_error_msg(&format!("failed to parse config: {:?}", e));
return -1;
}
0
}
/// # Safety
/// Run the network instance
pub(crate) unsafe fn run_network_instance(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int {
if in_config_server_callback() {
set_error_msg("cannot run network instance from config server callback");
return -1;
}
let cfg_str = unsafe {
assert!(!cfg_str.is_null());
std::ffi::CStr::from_ptr(cfg_str)
.to_string_lossy()
.into_owned()
};
let cfg = match TomlConfigLoader::new_from_str(&cfg_str) {
Ok(cfg) => cfg,
Err(e) => {
set_error_msg(&format!("failed to parse config: {}", e));
return -1;
}
};
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; mod config_server;
use easytier::{ mod data_plane;
common::config::{ConfigLoader as _, TomlConfigLoader}, mod error;
instance_manager::NetworkInstanceManager, mod instance_api;
launcher::ConfigSource, 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>> = use std::ffi::{c_char, c_int, c_void};
once_cell::sync::Lazy::new(DashMap::new);
static INSTANCE_MANAGER: once_cell::sync::Lazy<NetworkInstanceManager> =
once_cell::sync::Lazy::new(NetworkInstanceManager::new);
static ERROR_MSG: once_cell::sync::Lazy<Mutex<Vec<u8>>> = // ===== Network Management API =====
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);
}
/// 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 /// # Safety
/// Set the tun fd /// `cfg_str` must be a non-null pointer to a null-terminated UTF-8 string.
#[no_mangle] ///
pub unsafe extern "C" fn set_tun_fd( /// # Return
inst_name: *const std::ffi::c_char, /// Returns `0` if the config parses successfully, or `-1` on failure. On
fd: std::ffi::c_int, /// failure, call `get_error_msg` on the same thread to retrieve details.
) -> std::ffi::c_int { #[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
let inst_name = unsafe { pub unsafe extern "C" fn parse_config(cfg_str: *const c_char) -> c_int {
assert!(!inst_name.is_null()); unsafe { instance_api::parse_config(cfg_str) }
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,
}
} }
/// 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 /// # Safety
/// Get the last error message /// `cfg_str` must be a non-null pointer to a null-terminated UTF-8 string.
#[no_mangle] ///
pub unsafe extern "C" fn get_error_msg(out: *mut *const std::ffi::c_char) { /// # Return
let msg_buf = ERROR_MSG.lock().unwrap(); /// Returns `0` after the instance is started and registered in the FFI name
if msg_buf.is_empty() { /// cache, or `-1` on failure. On failure, call `get_error_msg` on the same
unsafe { /// thread to retrieve details.
*out = std::ptr::null(); #[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
} pub unsafe extern "C" fn run_network_instance(cfg_str: *const c_char) -> c_int {
return; unsafe { instance_api::run_network_instance(cfg_str) }
}
let cstr = std::ffi::CString::new(&msg_buf[..]).unwrap();
unsafe {
*out = cstr.into_raw();
}
}
#[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);
}
} }
/// 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 /// # Safety
/// Parse the config /// If `length > 0`, `inst_names` must be a non-null pointer to an array of
#[no_mangle] /// `length` non-null pointers to null-terminated UTF-8 strings.
pub unsafe extern "C" fn parse_config(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int { ///
let cfg_str = unsafe { /// # Return
assert!(!cfg_str.is_null()); /// Returns `0` on success, or `-1` on failure. On failure, call
std::ffi::CStr::from_ptr(cfg_str) /// `get_error_msg` on the same thread to retrieve details.
.to_string_lossy() #[cfg_attr(feature = "c-abi", unsafe(no_mangle))]
.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
#[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, ConfigSource::FFI) {
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
#[no_mangle]
pub unsafe extern "C" fn retain_network_instance( pub unsafe extern "C" fn retain_network_instance(
inst_names: *const *const std::ffi::c_char, inst_names: *const *const c_char,
length: usize, length: usize,
) -> std::ffi::c_int { ) -> c_int {
if length == 0 { unsafe { instance_api::retain_network_instance(inst_names, length) }
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
} }
/// 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 /// # Safety
/// Collect the network infos /// If `length > 0`, `inst_names` must be a non-null pointer to an array of
#[no_mangle] /// `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( pub unsafe extern "C" fn collect_network_infos(
infos: *mut KeyValuePair, infos: *mut KeyValuePair,
max_length: usize, max_length: usize,
) -> std::ffi::c_int { ) -> c_int {
if max_length == 0 { unsafe { instance_api::collect_network_infos(infos, max_length) }
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() {
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_network_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.clone()).unwrap().into_raw(),
value: std::ffi::CString::new(value).unwrap().into_raw(),
};
index += 1;
}
index as std::ffi::c_int
} }
#[cfg(test)] /// Attach a TUN file descriptor to a named network instance.
mod tests { ///
use super::*; /// 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] /// Call an exposed EasyTier RPC method using protobuf JSON.
fn test_parse_config() { ///
let cfg_str = r#" /// This generic bridge intentionally excludes instance lifecycle management
inst_name = "test" /// RPCs. Use the dedicated FFI APIs for starting, retaining, deleting, and
network = "test_network" /// collecting instances. `payload_json` must contain the protobuf JSON request,
"#; /// including any `instance` selector required by the target RPC.
let cstr = std::ffi::CString::new(cfg_str).unwrap(); ///
unsafe { /// `domain_name` may be null or empty. It is only used by
assert_eq!(parse_config(cstr.as_ptr()), 0); /// `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`.
///
#[test] /// On success, writes a newly allocated JSON response string to
fn test_run_network_instance() { /// `out_response_json`. The caller must release it with `free_string`.
let cfg_str = r#" ///
inst_name = "test" /// This API fails if called from a config-server event callback.
network = "test_network" ///
"#; /// # Safety
let cstr = std::ffi::CString::new(cfg_str).unwrap(); /// `service_name`, `method_name`, `payload_json`, and `out_response_json` must
unsafe { /// be non-null. String pointers must point to null-terminated UTF-8 strings.
assert_eq!(run_network_instance(cstr.as_ptr()), 0); /// `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,
}
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "easytier-ios"
version = "0.1.0"
edition.workspace = true
[lib]
crate-type = ["staticlib", "rlib"]
[dependencies]
serde_json.workspace = true
tracing.workspace = true
tracing-subscriber = { workspace = true, default-features = true, features = ["env-filter"] }
easytier-ffi = { workspace = true, features = [
"c-abi",
] }
[dev-dependencies]
uuid.workspace = true
easytier-core = { workspace = true, default-features = true }
easytier-ffi = { workspace = true, features = [
"c-abi",
"ffi-dataplane",
] }
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
#
# Build the easytier-ios static library slices for the Flutter iOS client.
#
# This script only runs on macOS: it needs the Apple SDK (aarch64-apple-ios*,
# x86_64-apple-ios targets) plus `lipo`. Run it from the EasyTier repository
# root or from this crate directory.
#
# rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
# ./build-xcframework.sh
#
# Output (workspace target directory + ./xcframework/sim):
# target/aarch64-apple-ios/release/libeasytier_ios.a (device)
# xcframework/sim/libeasytier_ios.a (simulator, lipo merged)
set -euo pipefail
CRATE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# The crate lives in a workspace; build artifacts land in the workspace root
# target directory regardless of the current directory.
WORKSPACE_ROOT="$(cd "${CRATE_DIR}/../.." && pwd)"
TARGET_DIR="${WORKSPACE_ROOT}/target"
OUT_DIR="${CRATE_DIR}/xcframework"
if [[ "$(uname)" != "Darwin" ]]; then
echo "error: build-xcframework.sh must run on macOS (needs Apple SDK, lipo)" >&2
exit 1
fi
cd "${WORKSPACE_ROOT}"
# The Rust iOS targets emit a `___chkstk_darwin` stack-probe call but do not
# link the compiler-rt archive that provides it. Point the linker at the
# matching device or simulator archive shipped inside the Xcode toolchain.
CLANG_BIN="$(xcrun --find clang)" # .../Toolchains/XcodeDefault.xctoolchain/usr/bin/clang
TOOLCHAIN_USR="${CLANG_BIN%/bin/clang}" # .../XcodeDefault.xctoolchain/usr
CLANG_RT_DIR="$(cd "${TOOLCHAIN_USR}/lib/clang" && cd "$(ls | sort -V | tail -1)/lib/darwin" && pwd)"
CLANG_RT_RUSTFLAGS="${RUSTFLAGS:-} -C link-arg=-L${CLANG_RT_DIR}"
echo "==> using libclang_rt from ${CLANG_RT_DIR}"
# kcp-sys's bindgen rejects the `-sim` in the aarch64-apple-ios-sim target
# triple; give bindgen an explicit simulator target so the C bindings build.
SIM_SDK="$(xcrun --sdk iphonesimulator --show-sdk-path)"
echo "==> building aarch64-apple-ios (device)"
RUSTFLAGS="${CLANG_RT_RUSTFLAGS} -C link-arg=-lclang_rt.ios" \
cargo build -p easytier-ios --release --target aarch64-apple-ios
echo "==> building aarch64-apple-ios-sim (Apple Silicon simulator)"
BINDGEN_EXTRA_CLANG_ARGS="--target=arm64-apple-ios17.0-simulator -isysroot ${SIM_SDK}" \
RUSTFLAGS="${CLANG_RT_RUSTFLAGS} -C link-arg=-lclang_rt.iossim" \
cargo build -p easytier-ios --release --target aarch64-apple-ios-sim
echo "==> building x86_64-apple-ios (Intel simulator)"
BINDGEN_EXTRA_CLANG_ARGS="--target=x86_64-apple-ios17.0-simulator -isysroot ${SIM_SDK}" \
RUSTFLAGS="${CLANG_RT_RUSTFLAGS} -C link-arg=-lclang_rt.iossim" \
cargo build -p easytier-ios --release --target x86_64-apple-ios
rm -rf "${OUT_DIR}"
mkdir -p "${OUT_DIR}/sim"
echo "==> lipo: merge simulator slices"
lipo -create \
"${TARGET_DIR}/aarch64-apple-ios-sim/release/libeasytier_ios.a" \
"${TARGET_DIR}/x86_64-apple-ios/release/libeasytier_ios.a" \
-output "${OUT_DIR}/sim/libeasytier_ios.a"
echo "==> done:"
echo " device: ${TARGET_DIR}/aarch64-apple-ios/release/libeasytier_ios.a"
echo " simulator: ${OUT_DIR}/sim/libeasytier_ios.a"
@@ -0,0 +1,158 @@
/**
* @file easytier-ios.h
* @brief iOS-facing C ABI for EasyTier.
*
* This library embeds EasyTier into an iOS app without a TUN device or
* NEPacketTunnel: it manages EasyTier instances and bridges to the EasyTier
* management RPC surface. Loopback port forwarding into the virtual network
* is configured through easytier_ios_call_json_rpc() with
* api.config.ConfigRpcService/PatchConfig port-forward patches; there is no
* built-in forwarder.
*
* Error handling: functions returning `int` return 0 on success and -1 on
* failure; functions returning `char *` return NULL on failure. Call
* easytier_ios_last_error() on the same thread to retrieve details.
*
* Threading: all functions are safe to call from any thread. The last-error
* buffer is thread-local, so query it on the thread that received the
* failure.
*/
#ifndef EASYTIER_IOS_H
#define EASYTIER_IOS_H
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/**
* @brief Configure persistent EasyTier diagnostic logging.
*
* Enabling writes targeted connection trace/debug events into rotating log
* files in `directory`; disabling turns the filter off and flushes output.
*
* @param directory UTF-8 directory path. Required when enabling; ignored when
* disabling.
* @param enabled Non-zero to enable, zero to disable.
* @return 0 on success, -1 on failure.
*/
int easytier_ios_configure_diagnostic_logging(const char *directory,
int enabled);
/**
* @brief Append a host lifecycle or network-path marker to the active log.
*
* This is a no-op while diagnostic logging is disabled.
*
* @param message Non-null NUL-terminated UTF-8 event text.
* @return 0 on success, -1 on failure.
*/
int easytier_ios_append_diagnostic_event(const char *message);
/** @brief Flush diagnostic log output. */
int easytier_ios_flush_diagnostic_logging(void);
/** @brief Delete all diagnostic log content and reopen the active log. */
int easytier_ios_clear_diagnostic_logs(void);
/**
* @brief Start one EasyTier network instance from a TOML config string.
*
* The config's `instance_name` must be unique among instances started
* through this library.
*
* @param toml Non-null pointer to a NUL-terminated UTF-8 TOML config string.
* @return 0 on success, -1 on failure.
*/
int easytier_ios_run_instance(const char *toml);
/**
* @brief Keep the named instances and stop all others.
*
* @param names_json Null, empty, or a NUL-terminated JSON array of instance
* name strings. Null / empty / `[]` stops every running
* instance.
* @return 0 on success, -1 on failure.
*/
int easytier_ios_retain_instances(const char *names_json);
/**
* @brief Stop exactly one named instance without affecting other instances.
*
* An unknown name is a no-op.
*
* @param instance_name Non-null NUL-terminated instance name.
* @return 0 on success, -1 on failure.
*/
int easytier_ios_delete_instance(const char *instance_name);
/**
* @brief Collect running instance information as a JSON object.
*
* The result maps each instance name to its running info JSON object.
*
* @param max_length Maximum number of instances to report.
* @return A newly allocated NUL-terminated JSON string on success, NULL on
* failure.
*
* @ownership The caller owns the returned string and must release it with
* easytier_ios_free_string().
*/
char *easytier_ios_collect_network_infos(int max_length);
/**
* @brief Call an exposed EasyTier management RPC method using protobuf JSON.
*
* `service_name` is the protobuf service name (e.g.
* "api.config.ConfigRpcService"), `method_name` the RPC method name (e.g.
* "PatchConfig"). `payload_json` must contain the protobuf JSON request,
* including any `instance` selector required by the target RPC.
*
* Port forwarding into the virtual network is driven through this bridge
* with api.config.ConfigRpcService/PatchConfig port-forward patches.
*
* @param service_name Non-null NUL-terminated RPC service name.
* @param method_name Non-null NUL-terminated RPC method name.
* @param payload_json Non-null NUL-terminated protobuf JSON request body.
* @return A newly allocated NUL-terminated JSON response string on success,
* NULL on failure.
*
* @ownership The caller owns the returned string and must release it with
* easytier_ios_free_string().
*/
char *easytier_ios_call_json_rpc(const char *service_name,
const char *method_name,
const char *payload_json);
/**
* @brief Return the last error message on this thread.
*
* Combines wrapper-side errors recorded by this library with the
* easytier-ffi last FFI error.
*
* @return A newly allocated NUL-terminated string, or NULL when there is no
* recorded error.
*
* @ownership The caller owns the returned string and must release it with
* easytier_ios_free_string().
*/
char *easytier_ios_last_error(void);
/**
* @brief Release a string returned by this library.
*
* Use this for strings returned by easytier_ios_collect_network_infos(),
* easytier_ios_call_json_rpc() and easytier_ios_last_error(). Passing NULL
* is a no-op. The string must not be used after this call.
*
* @param s NULL, or a string previously returned by this library.
*/
void easytier_ios_free_string(char *s);
#ifdef __cplusplus
}
#endif
#endif /* EASYTIER_IOS_H */
@@ -0,0 +1,308 @@
use std::{
fs::{self, File, OpenOptions},
io::{self, Write},
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use tracing_subscriber::fmt::MakeWriter;
pub(crate) const MAX_LOG_BYTES: u64 = 5 * 1024 * 1024;
pub(crate) const MAX_LOG_FILES: usize = 4;
#[derive(Clone)]
pub(crate) struct DiagnosticMakeWriter {
inner: Arc<Mutex<RotatingLog>>,
}
impl DiagnosticMakeWriter {
pub(crate) fn new(directory: &Path) -> io::Result<Self> {
Ok(Self {
inner: Arc::new(Mutex::new(RotatingLog::open(directory)?)),
})
}
pub(crate) fn set_directory(&self, directory: &Path) -> io::Result<()> {
self.lock()?.set_directory(directory)
}
pub(crate) fn clear(&self) -> io::Result<()> {
self.lock()?.clear()
}
pub(crate) fn flush(&self) -> io::Result<()> {
self.lock()?.flush()
}
fn lock(&self) -> io::Result<std::sync::MutexGuard<'_, RotatingLog>> {
self.inner
.lock()
.map_err(|_| io::Error::other("diagnostic log lock poisoned"))
}
}
impl<'a> MakeWriter<'a> for DiagnosticMakeWriter {
type Writer = BufferedEventWriter;
fn make_writer(&'a self) -> Self::Writer {
BufferedEventWriter {
target: self.clone(),
buffer: Vec::new(),
}
}
}
pub(crate) struct BufferedEventWriter {
target: DiagnosticMakeWriter,
buffer: Vec<u8>,
}
impl BufferedEventWriter {
fn commit(&mut self) -> io::Result<()> {
if self.buffer.is_empty() {
return Ok(());
}
let buffer = std::mem::take(&mut self.buffer);
self.target.lock()?.write_event(&buffer)
}
}
impl Write for BufferedEventWriter {
fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
self.buffer.extend_from_slice(buffer);
Ok(buffer.len())
}
fn flush(&mut self) -> io::Result<()> {
self.commit()
}
}
impl Drop for BufferedEventWriter {
fn drop(&mut self) {
let _ = self.commit();
}
}
struct RotatingLog {
directory: PathBuf,
active: Option<File>,
active_bytes: u64,
}
impl RotatingLog {
fn open(directory: &Path) -> io::Result<Self> {
fs::create_dir_all(directory)?;
let mut log = Self {
directory: directory.to_owned(),
active: None,
active_bytes: 0,
};
log.truncate_oversized_files()?;
log.open_active()?;
Ok(log)
}
fn set_directory(&mut self, directory: &Path) -> io::Result<()> {
if self.directory == directory && self.active.is_some() {
return Ok(());
}
self.flush()?;
self.active = None;
self.directory = directory.to_owned();
fs::create_dir_all(directory)?;
self.truncate_oversized_files()?;
self.open_active()
}
fn active_path(&self) -> PathBuf {
self.directory.join("easytier.log")
}
fn rotated_path(&self, index: usize) -> PathBuf {
self.directory.join(format!("easytier.{index}.log"))
}
fn truncate_oversized_files(&self) -> io::Result<()> {
let paths = std::iter::once(self.active_path())
.chain((1..MAX_LOG_FILES).map(|index| self.rotated_path(index)));
for path in paths {
if path
.metadata()
.is_ok_and(|metadata| metadata.len() > MAX_LOG_BYTES)
{
OpenOptions::new()
.write(true)
.open(path)?
.set_len(MAX_LOG_BYTES)?;
}
}
Ok(())
}
fn open_active(&mut self) -> io::Result<()> {
let path = self.active_path();
let file = OpenOptions::new().create(true).append(true).open(&path)?;
self.active_bytes = file.metadata()?.len();
self.active = Some(file);
if self.active_bytes >= MAX_LOG_BYTES {
self.rotate()?;
}
Ok(())
}
fn write_event(&mut self, event: &[u8]) -> io::Result<()> {
if event.is_empty() {
return Ok(());
}
if self.active_bytes > 0
&& self.active_bytes.saturating_add(event.len() as u64) > MAX_LOG_BYTES
{
self.rotate()?;
}
let remaining = MAX_LOG_BYTES.saturating_sub(self.active_bytes) as usize;
let event = &event[..event.len().min(remaining)];
if let Some(active) = self.active.as_mut() {
active.write_all(event)?;
self.active_bytes += event.len() as u64;
}
Ok(())
}
fn rotate(&mut self) -> io::Result<()> {
self.flush()?;
self.active = None;
let oldest = self.rotated_path(MAX_LOG_FILES - 1);
if oldest.exists() {
fs::remove_file(oldest)?;
}
for index in (1..MAX_LOG_FILES - 1).rev() {
let source = self.rotated_path(index);
if source.exists() {
fs::rename(source, self.rotated_path(index + 1))?;
}
}
let active = self.active_path();
if active.exists() {
fs::rename(active, self.rotated_path(1))?;
}
self.active_bytes = 0;
self.active = Some(
OpenOptions::new()
.create(true)
.append(true)
.open(self.active_path())?,
);
Ok(())
}
fn clear(&mut self) -> io::Result<()> {
self.flush()?;
self.active = None;
for index in 1..MAX_LOG_FILES {
let path = self.rotated_path(index);
if path.exists() {
fs::remove_file(path)?;
}
}
let active = self.active_path();
if active.exists() {
fs::remove_file(&active)?;
}
self.active_bytes = 0;
self.active = Some(OpenOptions::new().create(true).append(true).open(active)?);
Ok(())
}
fn flush(&mut self) -> io::Result<()> {
match self.active.as_mut() {
Some(active) => active.flush(),
None => Ok(()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
struct TempDir(PathBuf);
impl TempDir {
fn new(name: &str) -> Self {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!("easytier-ios-{name}-{unique}"));
fs::create_dir_all(&path).unwrap();
Self(path)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn rotates_without_exceeding_file_limit() {
let directory = TempDir::new("rotation");
let mut log = RotatingLog::open(&directory.0).unwrap();
let event = vec![b'x'; (MAX_LOG_BYTES / 2 + 1) as usize];
for _ in 0..6 {
log.write_event(&event).unwrap();
}
log.flush().unwrap();
let files = fs::read_dir(&directory.0)
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert_eq!(files.len(), MAX_LOG_FILES);
assert!(
files
.iter()
.all(|entry| entry.metadata().unwrap().len() <= MAX_LOG_BYTES)
);
}
#[test]
fn clear_removes_rotated_content_and_keeps_active_file_writable() {
let directory = TempDir::new("clear");
let mut log = RotatingLog::open(&directory.0).unwrap();
let event = vec![b'x'; (MAX_LOG_BYTES / 2 + 1) as usize];
log.write_event(&event).unwrap();
log.write_event(&event).unwrap();
log.clear().unwrap();
log.write_event(b"after clear\n").unwrap();
log.flush().unwrap();
assert_eq!(fs::read(log.active_path()).unwrap(), b"after clear\n");
assert!(!log.rotated_path(1).exists());
}
#[test]
fn opening_truncates_oversized_known_files() {
let directory = TempDir::new("oversized");
for name in ["easytier.log", "easytier.1.log"] {
let file = File::create(directory.0.join(name)).unwrap();
file.set_len(MAX_LOG_BYTES + 1).unwrap();
}
let log = RotatingLog::open(&directory.0).unwrap();
for index in 1..MAX_LOG_FILES {
let path = log.rotated_path(index);
if path.exists() {
assert!(path.metadata().unwrap().len() <= MAX_LOG_BYTES);
}
}
assert!(log.active_path().metadata().unwrap().len() <= MAX_LOG_BYTES);
}
}
@@ -0,0 +1,70 @@
use std::{
cell::RefCell,
ffi::{CStr, CString, c_char},
ptr,
};
thread_local! {
// Thread-local last error for the easytier-ios C ABI. Wrapper-side
// argument/JSON failures are recorded here; easytier-ffi records
// instance/RPC failures in its own buffer. `last_error` merges both.
static LAST_ERROR: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
}
pub(crate) fn set_error(message: &str) {
LAST_ERROR.with(|cell| {
let mut buffer = cell.borrow_mut();
buffer.clear();
buffer.extend_from_slice(message.as_bytes());
});
}
pub(crate) fn clear_error() {
LAST_ERROR.with(|cell| cell.borrow_mut().clear());
}
fn thread_local_error() -> Option<String> {
LAST_ERROR.with(|cell| {
let buffer = cell.borrow();
if buffer.is_empty() {
None
} else {
Some(String::from_utf8_lossy(&buffer).into_owned())
}
})
}
fn ffi_error() -> Option<String> {
unsafe {
let mut error_ptr: *const c_char = ptr::null();
easytier_ffi::get_error_msg(&mut error_ptr);
if error_ptr.is_null() {
None
} else {
let error_str = CStr::from_ptr(error_ptr).to_string_lossy().into_owned();
easytier_ffi::free_string(error_ptr);
Some(error_str)
}
}
}
/// Merge both error layers: this wrapper's own thread-local buffer and
/// easytier-ffi's last FFI error.
pub(crate) fn last_error() -> Option<String> {
match (ffi_error(), thread_local_error()) {
(Some(ffi_error), Some(local_error)) => Some(format!("{local_error}; {ffi_error}")),
(Some(ffi_error), None) => Some(ffi_error),
(None, Some(local_error)) => Some(local_error),
(None, None) => None,
}
}
/// Copy the merged last error into a newly allocated C string (null when
/// there is no error). The caller owns the result and must release it with
/// `easytier_ios_free_string`.
pub(crate) fn last_error_raw() -> *mut c_char {
match last_error().and_then(|message| CString::new(message).ok()) {
Some(message) => message.into_raw(),
None => ptr::null_mut(),
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
use std::ffi::CString;
/// Build a NUL-terminated C string from a Rust string for FFI calls.
pub(crate) fn cstring_for(value: &str, what: &str) -> std::io::Result<CString> {
CString::new(value).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("{what} contains a null byte"),
)
})
}
+22 -5
View File
@@ -1,6 +1,23 @@
# easytier_magisk模块 # EasyTier Magisk 模块
magisk安装后重启
目录位置:/data/adb/modules/easytier_magisk 安装模块并重启后,EasyTier 会读取
配置文件位置://data/adb/modules/easytier_magisk/config/config.toml `/data/adb/modules/easytier_magisk/config/config.toml` 启动。
修改config.conf即可,修改后配置文件后去magisk app重新开关模块即可生效
## WebUI
KernelSU 及支持模块 WebUI 的分支管理器可直接从模块详情页打开配置
界面。Magisk 用户可安装兼容 KernelSU 模块 WebUI 的独立管理器后打开。
WebUI 使用 EasyTier 自身的配置模型加载和校验配置。点击“保存并重启”后,
模块会先验证配置,原子替换 `config.toml`,再重启正在运行的 EasyTier
进程。校验失败时原配置保持不变。
如果 `config/command_args` 存在,模块处于启动参数模式并会忽略
`config.toml`。WebUI 会显示提示并禁止保存;删除 `command_args` 后即可
使用 WebUI 管理配置。
## 手动配置
也可以直接编辑
`/data/adb/modules/easytier_magisk/config/config.toml`。保存后在模块管理器中
禁用 EasyTier,等待 10 秒后重新启用即可让配置生效。
+57 -26
View File
@@ -1,43 +1,74 @@
#!/data/adb/magisk/busybox sh #!/data/adb/magisk/busybox sh
MODDIR=${0%/*} MODDIR=${0%/*}
MODULE_PROP="${MODDIR}/module.prop" MODULE_PROP="${MODDIR}/module.prop"
IP_RULE_SCRIPT="${MODDIR}/hotspot_iprule.sh"
ET_STATUS="" ET_STATUS=""
REDIR_STATUS="" REDIR_STATUS=""
# 更新module.prop文件中的description IS_RUNNING=false
# 确保辅助脚本有执行权限
chmod +x "${IP_RULE_SCRIPT}" 2>/dev/null
# 更新 module.prop 文件中的 description
update_module_description() { update_module_description() {
local status_message=$1 local status_message=$1
sed -i "/^description=/c\description=[状态]${status_message}" ${MODULE_PROP} # 检查 module.prop 文件存在且 description 发生变化了再写入
if [ -f "${MODULE_PROP}" ]; then
local current_desc=$(grep "^description=" "${MODULE_PROP}")
local new_desc="description=[状态] ${status_message}"
if [ "${current_desc}" != "${new_desc}" ]; then
sed -i "s#^description=.*#${new_desc}#" "${MODULE_PROP}"
fi
fi
} }
# 判断程序启动状态
if [ -f "${MODDIR}/disable" ]; then if [ -f "${MODDIR}/disable" ]; then
ET_STATUS="已关闭" IS_RUNNING=false
elif pgrep -f 'easytier-core' >/dev/null; then ET_STATUS="主程序已关闭"
if [ -f "${MODDIR}/config/command_args"]; then
ET_STATUS="主程序已开启(启动参数模式)" elif pgrep -f "${MODDIR}/easytier-core" >/dev/null; then
IS_RUNNING=true
if [ -f "${MODDIR}/config/command_args" ]; then
ET_STATUS="主程序正在运行(启动参数模式)"
else else
ET_STATUS="主程序已开启(配置文件模式)" ET_STATUS="主程序正在运行(配置文件模式"
fi fi
elif [ -z "$ET_STATUS" ]; then
# 既没 disable 也没运行,说明是异常停止或未启动
ET_STATUS="主程序启动失败或未运行"
fi fi
#ET_STATUS不存在说明开启模块未正常运行,不修改状态 # 无论主程序是否运行,都允许切换“开关文件”的状态,以便下次生效
if [ -n "$ET_STATUS" ]; then if [ -f "${MODDIR}/enable_IP_rule" ]; then
if [ -f "${MODDIR}/enable_IP_rule" ]; then rm -f "${MODDIR}/enable_IP_rule"
rm -f "${MODDIR}/enable_IP_rule"
${MODDIR}/hotspot_iprule.sh del "${IP_RULE_SCRIPT}" del >/dev/null 2>&1
REDIR_STATUS="转发已禁用"
echo "热点子网转发已禁用" REDIR_STATUS="转发已禁用"
echo "[ET-NAT] IP rule disabled." >> "${MODDIR}/log.log" echo "热点子网转发已禁用"
else echo "[ET-NAT] Action: IP rule disabled." >> "${MODDIR}/log.log"
touch "${MODDIR}/enable_IP_rule"
${MODDIR}/hotspot_iprule.sh del
${MODDIR}/hotspot_iprule.sh add_once
REDIR_STATUS="转发已激活"
echo "热点子网转发已激活,热点开启后将自动将热点加入转发网络(要求已配置本地网络cidr=参数)。转发规则将随着热点开关而自动开关。该状态将保持到转发被禁用为止。"
echo "[ET-NAT] IP rule enabled." >> "${MODDIR}/log.log"
fi
update_module_description "${ET_STATUS} | ${REDIR_STATUS}"
else else
echo "主程序未正常启动,请先检查配置文件" touch "${MODDIR}/enable_IP_rule"
if [ "$IS_RUNNING" = true ]; then
"${IP_RULE_SCRIPT}" del >/dev/null 2>&1
"${IP_RULE_SCRIPT}" add_once
echo "转发规则将立即生效,无需重启"
else
echo "主程序未运行,转发规则将在下次启动时生效"
fi
REDIR_STATUS="转发已激活"
echo "----------------------------------"
echo "热点子网转发已激活"
echo "热点开启后将自动将热点加入转发网络"
echo "需要在配置中提前配置好 cidr 参数"
echo "----------------------------------"
echo "[ET-NAT] Action: IP rule enabled." >> "${MODDIR}/log.log"
fi fi
sync
update_module_description "${ET_STATUS}| ${REDIR_STATUS}"
+17 -10
View File
@@ -1,25 +1,32 @@
#!/bin/sh #!/bin/sh
WEBUI_SOURCE="../../easytier-web/config-generator/dist"
version=$(cat module.prop | grep 'version=' | awk -F '=' '{print $2}' | sed 's/ (.*//') if [ -f "${WEBUI_SOURCE}/index.html" ]; then
rm -rf ./webroot
mkdir -p ./webroot
cp -R "${WEBUI_SOURCE}/." ./webroot/
elif [ ! -f "./webroot/index.html" ]; then
echo "Error: WebUI 构建产物不存在,请先运行 pnpm --dir ../../easytier-web/config-generator build."
exit 1
fi
version='v'$(grep '^version =' ../../easytier/Cargo.toml | cut -d '"' -f 2) version=$(grep '^version =' ../../easytier/Cargo.toml | cut -d '"' -f 2)
if [ -z "$version" ]; then if [ -z "$version" ]; then
echo "Error: 版本号不存在." echo "Error: 版本号不存在."
exit 1 exit 1
fi fi
version="v${version}"
filename="easytier_magisk_${version}.zip" filename="easytier_magisk_${version}.zip"
echo $version echo "${version}"
if [ ! -f "./easytier-core" ] || [ ! -f "./easytier-cli" ] || [ ! -f "./easytier-web" ]; then
if [ -f "./easytier-core" ] && [ -f "./easytier-cli" ] && [ -f "./easytier-web" ]; then wget -O "easytier_last.zip" "https://github.com/EasyTier/EasyTier/releases/download/${version}/easytier-linux-aarch64-${version}.zip"
zip -r -o -X "$filename" ./ -x '.git/*' -x '.github/*' -x 'folder/*' -x 'build.sh' -x 'magisk_update.json'
else
wget -O "easytier_last.zip" https://github.com/EasyTier/EasyTier/releases/download/"$version"/easytier-linux-aarch64-"$version".zip
unzip -o easytier_last.zip -d ./ unzip -o easytier_last.zip -d ./
mv ./easytier-linux-aarch64/* ./ mv ./easytier-linux-aarch64/* ./
rm -rf ./easytier_last.zip rm -rf ./easytier_last.zip
rm -rf ./easytier-linux-aarch64 rm -rf ./easytier-linux-aarch64
zip -r -o -X "$filename" ./ -x '.git/*' -x '.github/*' -x 'folder/*' -x 'build.sh' -x 'magisk_update.json' fi
fi
zip -r -o -X "${filename}" ./ -x '.git/*' -x '.github/*' -x 'folder/*' -x 'build.sh' -x 'magisk_update.json'
@@ -16,7 +16,7 @@ rpc_portal = "0.0.0.0:15888"
network_name = "default" network_name = "default"
network_secret = "" network_secret = ""
[[peer]] #[[peer]]
#uri = "协议://中转ip:端口" #uri = "协议://中转ip:端口"
[flags] [flags]
@@ -29,9 +29,10 @@ latency_first = false
enable_exit_node = false enable_exit_node = false
no_tun = false no_tun = false
use_smoltcp = false use_smoltcp = false
foreign_network_whitelist = "*" relay_network_whitelist = "*"
disable_p2p = false disable_p2p = false
relay_all_peer_rpc = false relay_all_peer_rpc = false
disable_udp_hole_punching = false disable_udp_hole_punching = false
disable_tcp_hole_punching = false
+20 -9
View File
@@ -1,9 +1,20 @@
ui_print '安装完成' SKIPMOUNT=false
ui_print '当前架构为' + $ARCH PROPFILE=true
ui_print '当前系统版本为' + $API POSTFSDATA=true
ui_print '安装目录为: /data/adb/modules/easytier_magisk' LATESTARTSERVICE=true
ui_print '配置文件位置: /data/adb/modules/easytier_magisk/config/config.toml'
ui_print '如果需要自定义启动参数,可将 /data/adb/modules/easytier_magisk/config/command_args_sample 重命名为 command_args,并修改其中内容,使用自定义启动参数时会忽略配置文件' set_perm_recursive $MODPATH 0 0 0777 0777
ui_print '修改配置文件后在magisk app禁用应用再启动即可生效'
ui_print '点击操作按钮可启动/关闭热点子网转发,配合easytier的子网代理功能实现手机热点访问easytier网络' ui_print "系统架构为:$ARCH"
ui_print '记得重启' ui_print "系统 SDK 版本:$API"
ui_print "EasyTier 安装位置:/data/adb/modules/easytier_magisk"
ui_print "配置文件位置:/data/adb/modules/easytier_magisk/config/config.toml"
ui_print "如需使用启动参数模式,请将 /data/adb/modules/easytier_magisk/config/command_args_sample 重命名为 command_args,并修改其中的内容"
ui_print "config 目录中存在 command_args 文件时,模块会自动忽略 config.toml 文件"
ui_print "支持模块 WebUI 的管理器可直接打开配置界面;Magisk 用户可配合 KsuWebUI 使用"
ui_print "----------------------------------"
ui_print "注意!启动参数文件中不能存在 \" 和 ',配置文件则没有这个限制"
ui_print "----------------------------------"
ui_print "修改配置后无需重启设备,在 Magisk 中禁用 EasyTier 模块,等待 10 秒后重新启用即可让新配置生效"
ui_print "点击 Magisk 中模块左下角的“操作”按钮可以禁用或激活热点子网转发,使用该功能前需要在配置中提前配置好 cidr 参数"
ui_print "模块安装完成,重启设备生效"
@@ -2,64 +2,111 @@
MODDIR=${0%/*} MODDIR=${0%/*}
CONFIG_FILE="${MODDIR}/config/config.toml" CONFIG_FILE="${MODDIR}/config/config.toml"
COMMAND_ARGS="${MODDIR}/config/command_args"
LOG_FILE="${MODDIR}/log.log" LOG_FILE="${MODDIR}/log.log"
MODULE_PROP="${MODDIR}/module.prop" MODULE_PROP="${MODDIR}/module.prop"
EASYTIER="${MODDIR}/easytier-core" EASYTIER="${MODDIR}/easytier-core"
# 处理获取到的设备型号中可能出现的空格
BRAND=$(getprop ro.product.brand | tr ' ' '-')
MODEL=$(getprop ro.product.model | tr ' ' '-')
DEVICE_HOSTNAME="${BRAND}-${MODEL}"
REDIR_STATUS="" REDIR_STATUS=""
# 更新module.prop文件中的description # 更新 module.prop 文件中的 description
update_module_description() { update_module_description() {
local status_message=$1 local status_message=$1
sed -i "/^description=/c\description=[状态]${status_message}" ${MODULE_PROP} # 检查 module.prop 文件存在且 description 发生变化了再写入
if [ -f "${MODULE_PROP}" ]; then
local current_desc=$(grep "^description=" "${MODULE_PROP}")
local new_desc="description=[状态] ${status_message}"
if [ "${current_desc}" != "${new_desc}" ]; then
sed -i "s#^description=.*#${new_desc}#" "${MODULE_PROP}"
fi
fi
} }
if [ -f "${MODDIR}/enable_IP_rule" ]; then # 检查并初始化 TUN 设备
REDIR_STATUS="转发已激活"
else
REDIR_STATUS="转发已禁用"
fi
if [ ! -e /dev/net/tun ]; then if [ ! -e /dev/net/tun ]; then
if [ ! -d /dev/net ]; then if [ ! -d /dev/net ]; then
mkdir -p /dev/net mkdir -p /dev/net
fi fi
ln -s /dev/tun /dev/net/tun ln -s /dev/tun /dev/net/tun
fi fi
while true; do while true; do
if ls $MODDIR | grep -q "disable"; then # 获取子网转发激活状态
update_module_description "关闭中 | ${REDIR_STATUS}" if [ -f "${MODDIR}/enable_IP_rule" ]; then
if pgrep -f 'easytier-core' >/dev/null; then REDIR_STATUS="转发已激活"
echo "开关控制$(date "+%Y-%m-%d %H:%M:%S") 进程已存在,正在关闭 ..."
pkill easytier-core # 关闭进程
fi
else else
if ! pgrep -f 'easytier-core' >/dev/null; then REDIR_STATUS="转发已禁用"
if [ ! -f "$CONFIG_FILE" ]; then fi
update_module_description "config.toml不存在"
sleep 3s
continue
fi
# 如果 config 目录下存在 command_args 文件,则读取其中的内容作为启动参数 # 检查模块是否被禁用
if [ -f "${MODDIR}/config/command_args" ]; then if [ -f "${MODDIR}/disable" ]; then
TZ=Asia/Shanghai ${EASYTIER} $(cat ${MODDIR}/config/command_args) > ${LOG_FILE} & update_module_description "主程序已关闭 | ${REDIR_STATUS}"
sleep 5s # 等待easytier-core启动完成 if pgrep -f "${EASYTIER}" >/dev/null; then
update_module_description "主程序已开启(启动参数模式) | ${REDIR_STATUS}" echo "开关控制 $(date "+%Y-%m-%d %H:%M:%S") 进程已存在,正在关闭"
else pkill -f "${EASYTIER}"
TZ=Asia/Shanghai ${EASYTIER} -c ${CONFIG_FILE} > ${LOG_FILE} &
sleep 5s # 等待easytier-core启动完成
update_module_description "主程序已开启(配置文件模式) | ${REDIR_STATUS}"
fi
ip rule add from all lookup main
if ! pgrep -f 'easytier-core' >/dev/null; then
update_module_descriptio "主程序启动失败,请检查配置文件"
fi
else
echo "开关控制$(date "+%Y-%m-%d %H:%M:%S") 进程已存在"
fi fi
sleep 10s
continue
fi fi
sleep 3s # 暂停3秒后再次执行循环 # 检查进程是否已经在运行
if pgrep -f "${EASYTIER}" >/dev/null; then
sleep 10s
continue
fi
# 检查配置文件是否存在
if [ ! -f "${CONFIG_FILE}" ] && [ ! -f "${COMMAND_ARGS}" ]; then
update_module_description "缺少配置文件或启动参数文件"
sleep 10s
continue
fi
# 如果 config 目录下存在 command_args 文件,则读取其中的内容作为启动参数
if [ -f "${COMMAND_ARGS}" ]; then
# 启动参数模式
CMD_CONTENT=$(tr '\r\n' ' ' < "${COMMAND_ARGS}")
if echo "${CMD_CONTENT}" | grep -q "\-\-hostname"; then
FINAL_ARGS="${CMD_CONTENT}"
else
FINAL_ARGS="${CMD_CONTENT} --hostname ${DEVICE_HOSTNAME}"
fi
TZ=Asia/Shanghai "${EASYTIER}" ${FINAL_ARGS} > "${LOG_FILE}" 2>&1 &
STR_MODE="启动参数模式"
# 否则读取 config.toml 的内容作为启动参数
else
# 配置文件模式
if grep -q "^[[:space:]]*hostname[[:space:]]*=" "${CONFIG_FILE}"; then
TZ=Asia/Shanghai "${EASYTIER}" -c "${CONFIG_FILE}" > "${LOG_FILE}" 2>&1 &
else
TZ=Asia/Shanghai "${EASYTIER}" -c "${CONFIG_FILE}" --hostname "${DEVICE_HOSTNAME}" > "${LOG_FILE}" 2>&1 &
fi
STR_MODE="配置文件模式"
fi
# 等待进程启动
sleep 5s
# 启动后的扫尾工作
if pgrep -f "${EASYTIER}" >/dev/null; then
if ! ip rule show | grep -qE '^[0-9]+:[[:space:]]+from all lookup main$'; then
ip rule add from all lookup main
fi
update_module_description "主程序正在运行(${STR_MODE}| ${REDIR_STATUS}"
else
update_module_description "主程序启动失败,请检查配置文件或启动参数"
fi
sleep 10s
done done
+1 -1
View File
@@ -1,6 +1,6 @@
id=easytier_magisk id=easytier_magisk
name=EasyTier_Magisk name=EasyTier_Magisk
version=v2.4.5 version=v2.7.0
versionCode=1 versionCode=1
author=EasyTier author=EasyTier
description=easytier magisk module @EasyTier(https://github.com/EasyTier/EasyTier) description=easytier magisk module @EasyTier(https://github.com/EasyTier/EasyTier)
@@ -1,3 +1,5 @@
MODDIR=${0%/*} MODDIR=${0%/*}
pkill easytier-core # 结束 easytier-core 进程 pkill -f "${MODDIR}/easytier-core"
rm -rf $MODDIR/*
# 使用 ${MODDIR:?} 确保变量非空,避免执行 rm -rf /*
rm -rf "${MODDIR:?}/"*
+77
View File
@@ -0,0 +1,77 @@
#!/system/bin/sh
MODDIR=$(CDPATH= cd "${0%/*}" 2>/dev/null && pwd -P) || {
echo "Failed to resolve module directory" >&2
exit 1
}
CONFIG_FILE="${MODDIR}/config/config.toml"
COMMAND_ARGS="${MODDIR}/config/command_args"
EASYTIER="${MODDIR}/easytier-core"
fail() {
echo "$1" >&2
exit 1
}
running_pids() {
for process_dir in /proc/[0-9]*; do
[ "$(readlink "${process_dir}/exe" 2>/dev/null)" = "${EASYTIER}" ] || continue
printf '%s\n' "${process_dir#/proc/}"
done
}
is_running() {
[ -n "$(running_pids)" ]
}
case "$1" in
status)
if is_running; then
echo "running"
else
echo "stopped"
fi
;;
config-mode)
if [ -f "${COMMAND_ARGS}" ]; then
echo "command-args"
else
echo "toml"
fi
;;
read-config)
[ -f "${CONFIG_FILE}" ] || fail "Configuration file not found: ${CONFIG_FILE}"
cat "${CONFIG_FILE}"
;;
save-and-restart)
[ -f "${COMMAND_ARGS}" ] && fail "command_args is active; config.toml would be ignored"
[ -n "$2" ] || fail "Missing base64-encoded configuration"
[ -x "${EASYTIER}" ] || fail "easytier-core is not executable"
TEMP_FILE="${CONFIG_FILE}.webui.$$"
trap 'rm -f "${TEMP_FILE}"' EXIT HUP INT TERM
umask 077
printf '%s' "$2" | base64 -d > "${TEMP_FILE}" \
|| fail "Failed to decode configuration"
VALIDATION_OUTPUT=$("${EASYTIER}" --check-config -c "${TEMP_FILE}" 2>&1)
if [ $? -ne 0 ]; then
fail "${VALIDATION_OUTPUT:-Configuration validation failed}"
fi
chmod 0644 "${TEMP_FILE}"
mv -f "${TEMP_FILE}" "${CONFIG_FILE}" \
|| fail "Failed to replace configuration"
trap - EXIT HUP INT TERM
RUNNING_PIDS=$(running_pids)
for pid in ${RUNNING_PIDS}; do
kill "${pid}" || fail "Failed to restart EasyTier"
done
echo "saved"
;;
*)
fail "Usage: $0 {status|config-mode|read-config|save-and-restart BASE64}"
;;
esac
+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.7.0"
edition.workspace = true
rust-version.workspace = true
license-file = "../../LICENSE"
build = "build.rs"
[dependencies]
anyhow.workspace = true
easytier = { workspace = true, features = [
"aes-gcm",
"dhcp-ipv4",
"logging",
"proxy-cidr-monitor",
"smoltcp",
"tun",
"web-client",
] }
tokio = { workspace = true, 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);
}
}
@@ -0,0 +1,9 @@
dist/
target/
.DS_Store
.idea/
package/libs
*.har
Cargo.lock
+2485 -1853
View File
File diff suppressed because it is too large Load Diff
+32 -7
View File
@@ -1,3 +1,10 @@
[workspace]
members = [
"crates/easytier-ohos-features",
"crates/easytier-ohos-core",
]
resolver = "2"
[package] [package]
name = "easytier-ohrs" name = "easytier-ohrs"
version = "0.1.0" version = "0.1.0"
@@ -7,10 +14,21 @@ edition = "2024"
crate-type=["cdylib"] crate-type=["cdylib"]
[dependencies] [dependencies]
ohos-hilog-binding = {version = "*", features = ["redirect"]} anyhow = "1.0"
easytier = { git = "https://github.com/EasyTier/EasyTier.git" } async-trait = "0.1"
napi-derive-ohos = "1.0.4" bytes = "1.5"
napi-ohos = { version = "1.0.4", default-features = false, features = [ easytier-core = { path = "../../easytier-core", default-features = false }
easytier-ohos-features = { path = "crates/easytier-ohos-features" }
easytier-ohos-core = { path = "crates/easytier-ohos-core" }
easytier-proto = { path = "../../easytier-proto", default-features = false, features = [
"api",
"core",
"json-rpc",
] }
futures = "0.3"
easytier = { path = "../../easytier" }
napi-derive-ohos = "1.1"
napi-ohos = { version = "1.1", default-features = false, features = [
"serde-json", "serde-json",
"latin1", "latin1",
"chrono_date", "chrono_date",
@@ -22,18 +40,25 @@ napi-ohos = { version = "1.0.4", default-features = false, features = [
"tokio_io_util", "tokio_io_util",
"deferred_trace", "deferred_trace",
"napi8", "napi8",
"node_version_detect",
"web_stream", "web_stream",
] } ] }
once_cell = "1.21.3" once_cell = "1.21.3"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.125" serde_json = "1.0.125"
tracing-subscriber = "0.3.19" tracing-subscriber = "0.3.19"
tracing-core = "0.1.33" tracing-core = "0.1.33"
tracing = "0.1.41" tracing = "0.1.41"
uuid = { version = "1.17.0", features = ["v4"] } tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] }
url = "2.5"
uuid = { version = "1.5.0", features = [
"v4",
"fast-rng",
"macro-diagnostics",
"serde",
] }
[build-dependencies] [build-dependencies]
napi-build-ohos = "1.0.4" napi-build-ohos = "1.1"
[profile.dev] [profile.dev]
panic = "unwind" panic = "unwind"
debug = true debug = true
+2 -2
View File
@@ -1,3 +1,3 @@
fn main () { fn main() {
napi_build_ohos::setup(); napi_build_ohos::setup();
} }
@@ -0,0 +1,29 @@
# HarmonyOS Rust package boundaries
`easytier-ohrs` keeps the single `.so`/HAR and N-API compatibility surface consumed by ArkTS, but its Rust implementation is split by responsibility:
- **`easytier-ohos-core`** owns the process Tokio runtime, `NativeInstanceManager`, runtime-state projections, kernel socket protocol DTOs, TUN route aggregation, and the platform handshake used to protect individual transport sockets. Code that starts, stops, observes, or translates EasyTier runtime state belongs here.
- **`easytier-ohos-features`** owns configuration persistence and migration, SQLite metadata/field storage, schema reflection, validation, import/export, and share links. Code that remains meaningful without a running EasyTier instance belongs here.
- **`easytier-ohrs`** is the platform facade. It owns N-API exports, HarmonyOS platform logging and nearby-management adapters, and the small amount of orchestration that passes a validated feature configuration into the kernel package.
## Dependency direction
```text
ArkTS/HAR
|
easytier-ohrs (N-API facade)
| |
v v
easytier-ohos-core easytier-ohos-features
```
The facade passes owned EasyTier configuration values into the kernel package when runtime state must be projected. The kernel and feature packages do not depend on each other, so another HarmonyOS application can reuse the kernel integration without pulling in this client's SQLite repository, migrations, schema UI metadata, or share-link services.
## Boundary rules
1. SQLite, schema reflection, import/export, and share-link code must not enter `easytier-ohos-core`.
2. Tokio runtime ownership, instance lifecycle, TUN attachment, kernel protocol, and runtime-state conversion must not enter `easytier-ohos-features`.
3. New ArkTS exports remain in the outer `easytier-ohrs` facade so the HAR continues to expose one stable native module.
4. Cross-package values should be owned DTOs/snapshots; feature code must not receive runtime-manager handles.
5. Kernel code must not read the feature package's repository or global storage state; the facade supplies the validated runtime values it needs.
6. The split is semantic and architectural. It is not presented as a configuration-page frame-time optimization.
@@ -0,0 +1,17 @@
[package]
name = "easytier-ohos-core"
version = "0.1.0"
edition = "2024"
description = "HarmonyOS-side EasyTier runtime, instance lifecycle and kernel interaction state"
publish = false
[dependencies]
async-trait = "0.1"
easytier = { path = "../../../../easytier" }
easytier-core = { path = "../../../../easytier-core", default-features = false }
ipnet = "2.10"
once_cell = "1.21.3"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.125"
tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "time"] }
url = "2.5"
@@ -0,0 +1,56 @@
pub mod protocol;
pub mod routing;
pub mod runtime;
pub mod socket_protection;
use easytier::instance::factory::{NativeInstanceManager, native_instance_manager_with_runtime};
use once_cell::sync::Lazy;
use std::sync::Arc;
use tokio::runtime::{Builder, Runtime};
/// The single Tokio runtime that owns HarmonyOS kernel and web-client work.
pub static ASYNC_RUNTIME: Lazy<Runtime> = Lazy::new(|| {
Builder::new_multi_thread()
.enable_all()
.build()
.expect("tokio runtime for easytier-ohos-core")
});
/// Process-wide EasyTier instance manager. Keeping it in the kernel crate prevents feature/storage
/// code from acquiring lifecycle ownership.
pub static INSTANCE_MANAGER: Lazy<Arc<NativeInstanceManager>> = Lazy::new(|| {
Arc::new(native_instance_manager_with_runtime(
ASYNC_RUNTIME.handle().clone(),
))
});
#[cfg(test)]
mod architecture_tests {
fn assert_no_napi_annotations(path: &std::path::Path) {
for entry in std::fs::read_dir(path).expect("read source directory") {
let path = entry.expect("read source entry").path();
if path.is_dir() {
assert_no_napi_annotations(&path);
} else if path.extension().is_some_and(|extension| extension == "rs") {
let source = std::fs::read_to_string(&path).expect("read Rust source");
let marker = ["#[", "napi"].concat();
assert!(!source.contains(&marker), "N-API annotation in {path:?}");
}
}
}
#[test]
fn inner_crate_has_no_napi_registration_dependency() {
let manifest = include_str!("../Cargo.toml");
let runtime_dependency = ["napi", "ohos"].join("-");
let derive_dependency = ["napi", "derive", "ohos"].join("-");
assert!(!manifest.contains(&runtime_dependency));
assert!(!manifest.contains(&derive_dependency));
assert!(!manifest.contains("easytier-ohos-features"));
assert_no_napi_annotations(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("src")
.as_path(),
);
}
}
@@ -0,0 +1,137 @@
use serde::Serialize;
use std::io::{Error, ErrorKind, Write};
use std::os::unix::net::UnixStream;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LocalSocketSyncMessage {
pub message_type: String,
pub payload_json: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub 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 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 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 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 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
}
#[cfg(test)]
mod tests {
use super::{broadcast_local_socket_message, send_local_socket_message};
use std::io::Read;
use std::os::unix::net::UnixStream;
#[test]
fn local_socket_message_uses_camel_case_newline_frame() {
let (mut sender, mut receiver) = UnixStream::pair().expect("socket pair");
send_local_socket_message(&mut sender, "runtimeState", "{\"ok\":true}".to_string())
.expect("send frame");
sender
.shutdown(std::net::Shutdown::Write)
.expect("shutdown");
let mut raw = String::new();
receiver.read_to_string(&mut raw).expect("read frame");
assert_eq!(
raw,
"{\"messageType\":\"runtimeState\",\"payloadJson\":\"{\\\"ok\\\":true}\"}\n"
);
}
#[test]
fn broadcast_removes_disconnected_clients() {
let (sender, receiver) = UnixStream::pair().expect("socket pair");
drop(receiver);
let mut clients = vec![sender];
assert!(!broadcast_local_socket_message(
&mut clients,
"runtimeState",
"{}"
));
assert!(clients.is_empty());
}
}
@@ -0,0 +1,150 @@
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 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 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(instance.manual_routes.iter().cloned());
// Local proxy CIDRs are advertisements for networks reached through this
// node. Installing them into the same local TUN would recapture the proxy's
// own destination sockets instead of using the physical network.
raw_routes.extend(runtime_proxy_cidrs.iter().cloned());
simplify_routes(raw_routes)
}
pub 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
}
#[cfg(test)]
mod tests {
use super::{aggregate_tun_routes, simplify_routes};
use crate::runtime::state::runtime_state::{RouteView, runtime_instance_from_config_snapshot};
use easytier::proto::api::manage::NetworkConfig;
#[test]
fn simplify_routes_normalizes_deduplicates_and_removes_subnets() {
let routes = simplify_routes(vec![
"10.0.0.7".to_string(),
"10.0.0.0/24".to_string(),
"10.0.0.42/32->peer-a".to_string(),
"2001:db8::1".to_string(),
"2001:db8::/64".to_string(),
]);
assert_eq!(routes, vec!["10.0.0.0/24", "2001:db8::/64"]);
}
#[test]
fn local_proxy_cidr_is_not_installed_in_tun_routes() {
let mut instance = runtime_instance_from_config_snapshot(
"routing-test".to_string(),
"test".to_string(),
NetworkConfig {
virtual_ipv4: Some("10.144.144.1".to_string()),
network_length: Some(24),
routes: vec!["172.16.0.0/16".to_string()],
proxy_cidrs: vec!["192.168.1.0/24".to_string()],
..Default::default()
},
true,
);
instance.routes.push(RouteView {
peer_id: 2,
hostname: None,
ipv4: Some("10.144.144.2".to_string()),
ipv4_cidr: Some("10.144.144.2/24".to_string()),
ipv6_cidr: None,
proxy_cidrs: vec!["10.20.0.0/16".to_string()],
next_hop_peer_id: Some(2),
cost: Some(1),
path_latency: None,
udp_nat_type: None,
tcp_nat_type: None,
inst_id: None,
version: None,
is_public_server: None,
});
let routes = aggregate_tun_routes(&instance);
assert!(routes.contains(&"10.144.144.0/24".to_string()));
assert!(routes.contains(&"172.16.0.0/16".to_string()));
assert!(routes.contains(&"10.20.0.0/16".to_string()));
assert!(!routes.contains(&"192.168.1.0/24".to_string()));
}
}
@@ -0,0 +1 @@
pub mod state;

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