Compare commits

..
Author SHA1 Message Date
KKRainbow a24cc0a1bb fix(socket): keep port forwards usable after interface recreation
Bind TCP and UDP port-forward listeners to their configured address
instead of implicitly pinning them to the current network interface.
An interface recreated with the same name and IP gets a new ifindex,
leaving device-bound listeners unable to receive traffic while accept
continues waiting. Keep explicit device-binding options unchanged.

Add TCP and UDP three-node regressions that recreate a TUN interface
without restarting the forwarding instance, plus device-option checks.

Fixes #2714.
2026-10-21 00:39:23 +08:00
KKRainbow 318a0b0686 fix(gateway): make port-forward reload preserve live listeners (#2710)
Reuse listeners by protocol and bind address when their destination
changes, keeping established TCP streams connected to the old target.
Prepare all new sockets before persistence or configuration publication
so bind and storage failures leave committed forwarding usable.

Commit prepared resources with shared TOML and runtime publication.
Serialize UDP target changes with flow insertion, and track response
tasks through eviction so removal waits for every socket reference.
Keep cleanup and its operation lock alive if the caller stops waiting.

Add TCP and UDP regressions for target changes, failed updates, retries,
established streams, immediate rebinds, and cancelled cleanup waiters.
2026-10-21 00:23:10 +08:00
KKRainbow c7e636f918 fix(udp): keep native UDP bindings exclusive by default (#2731)
* fix(udp): honor exclusive bind defaults for native sockets

Stop enabling SO_REUSEADDR implicitly for UDP listeners, proxy NAT
sockets and port forwards. Let UdpBindOptions own the reuse request so
independent receivers cannot bind the same endpoint or receive another
network instance's traffic.

Cover occupied IPv4/IPv6 endpoints, release and rebinding, and packet
isolation across concurrent ephemeral listeners, NAT and forward sockets.
The occupied-listener regression fails on the parent revision; all nine
native UDP socket tests pass with the fix.

* test(udp): reject duplicate WireGuard listener bindings

Update the WireGuard listener test to expect AddrInUse now that native
UDP sockets honor the exclusive bind default. Allocate the first port
from the kernel so the test does not depend on a fixed endpoint.

* test(udp): preserve dual-stack WireGuard port sharing

Verify IPv6-only and IPv4 wildcard listeners can use the same port
number while exact duplicate endpoints remain rejected. This preserves
the dual-stack bind scenario without enabling UDP address reuse.
2026-10-21 00:22:40 +08:00
KKRainbow 5af90471bf fix(vpn): inherit portal exit nodes for attached clients (#2726) 2026-10-19 00:04:29 +08:00
KKRainbow a144597d02 fix(core): show complete instance startup errors (#2724)
Restore the CLI's default instance-manager diagnostics without enabling
unrelated module logs. Include failed instance IDs and their saved error
chains in the final exit error.

Attach the listener URL to required-listener failures and render full
error chains in startup logs, so bind failures expose their OS cause.

Extend listener regression coverage and document the default CLI targets.
2026-10-18 22:28:41 +08:00
Luna Yao 4ff27c7ef4 fix(cli): support loose boolean values for flags (#2721)
Allow boolean flags to accept 1/0, yes/no, on/off in addition to true/false using clap's BoolishValueParser.
2026-10-18 20:06:33 +08:00
KKRainbow f1b910da4e test: make secure datagram grace expiry deterministic (#2723)
The expiry test sampled time before sync_root_key established its grace
period. Scheduling delays over 3 ms could leave the asserted timestamp
inside the grace window and fail the test.

Use the actual grace deadline to check acceptance just before expiry and
rejection at expiry. Use a fresh sequence number for the rejection check
so replay detection cannot hide a broken expiry boundary.
2026-10-18 12:36:54 +08:00
KKRainbow e25c6bdd0c fix(web): prevent heartbeat replay and unsafe receiver takeover (#2718)
* fix(web): stop replaying heartbeats after token validation

Wake runtime reconciliation once when authentication establishes the
session. Repeated successful validation, including a binding refresh,
must not replay a heartbeat whose instance list predates a completed
Run.

Use existing notifications as wakeups and read the latest session
heartbeat when reconciling, so delayed validation and config callbacks
cannot replace a newer runtime observation with a cached payload.

Cover one initial wakeup, 99 successful validations without replay, and
a subsequent real heartbeat that still wakes runtime reconciliation.

* fix(web): wait for authoritative config during receiver takeover

Keep the desired webhook revision on the authenticated session. Gate
runtime reconciliation, mutations and applied acknowledgements until the
receiver has that exact revision, preserving running Core instances
while its local configuration is missing or stale.

After committed Full/Patch updates, request current validation authority
without letting delayed publication callbacks or validation responses
restore an older target. After the connected ACK establishes the route,
wake existing validation once when configuration is still unavailable.

Cover missing and obsolete state, authoritative empty sets, stale
rounds, callback races, live Core handoff and an explicit
1000-connection takeover exercise with an independent mount.
2026-10-17 11:44:48 +08:00
KKRainbow 585628ecf1 test(dhcp): wait for dual-stack address and route convergence (#2719) 2026-10-17 09:17:47 +08:00
KKRainbow 8c2f67e13e fix(windows): stop running GUI and services during installer updates (#2717)
* fix(gui): stop Windows processes before installing updates

Keep the NSIS installer in the current user context. Detect the target
GUI and service before updating, and elevate a temporary helper only
when stopping them requires administrator access.

Stop services through SCM and wait for their processes to exit before
closing the GUI. Retain the helper until installation completes so it
can restore a previously running service without another UAC prompt.
Restore the service on cancellation when its executable still exists.

Prepare before Tauri launches an old uninstaller, and cover passive,
silent, and direct uninstall paths. Add Windows service lifecycle tests
with isolated executable fixtures.

* fix(windows): report stopped before normal service exit

Report SERVICE_STOPPED with no accepted controls before exiting after
an explicit stop or successful completion. Reporting Running at exit
makes SCM treat a normal stop as a failure and schedule a restart.

This fixes the service lifecycle independently of installer changes.

* fix(gui): prevent old services restarting during Windows updates

Temporarily disable service startup before requesting a stop, then
restore the original startup mode when the update finishes or exits.
Released services can exit without reporting Stopped, causing SCM to
queue a recovery restart even after an explicit stop request.

Extend the Windows fixture to reproduce that exit behavior with failure
recovery enabled and check that startup settings are restored. Record
the minor directory-change and GUI-relaunch limitation from review.

* docs(windows): record installer short-path matching limitation

Record the 8.3 path alias mismatch found while testing the installer
helper without administrator privileges. Document how to run the fixture
with a full temporary-directory path and retain the issue as a minor
follow-up without expanding the installer implementation.
2026-10-17 02:15:57 +08:00
KKRainbow c7442868ab fix(ci): exclude nightly tags and isolate shared TUN cases (#2716)
Restrict OHOS version baselines to v-prefixed version tags so the
rolling nightly tag cannot produce an invalid ohpm package version.
Keep the manifest version fallback and count commits from the selected
version tag.

Give each parameterized shared TUN test its own network namespace,
while keeping both member instances on the same TUN within that
namespace. Reuse TestNetwork to own and clean up the namespaces and
remove the process-local serial lock, which does not serialize nextest
processes. This prevents fixed test addresses from competing for routes
in the root namespace without increasing timeouts.
2026-10-16 22:14:53 +08:00
KKRainbow 3384cc1162 test: synchronize BPF veth initialization before sending (#2712) 2026-10-16 19:12:16 +08:00
KKRainbowandCodex 99db92a3ae fix(config): preserve and patch empty manual route mode (#2709)
* fix(config): preserve empty manual route mode in API conversion

Treat present TOML routes as manual mode even when the list is empty.
Both Core readback and Web normalization must distinguish routes=[]
from automatic discovery; otherwise Web can skip a required overwrite
and Core can report a different mode from its running configuration.

Cover API/TOML round trips and receiver reconcile mode transitions.
Verify default-feature tests, clippy, formatting and real system routes
in three isolated Linux network namespaces.

* fix(config): patch manual route mode and verify legacy readback

Add an explicit manual-route mode to route patches and advertise typed
readback support through GetConfig. Apply the mode and route edits in the
existing route transaction without restarting the instance; keep released
callers that omit the new field on their existing route semantics.

Web gates route patches on the observed capability. For old clients with
ambiguous empty routes, recover the actual mode from GetConfig TOML or
instance-scoped ShowNodeInfo before acknowledging application. Supporting
clients use typed readback directly. Keep capability evidence in the
existing session cache and preserve its invalidation rules.

Cover mode transitions, persistence failure, stable peer identity, legacy
readback, compact clients, failed observations and cache capability gates.
Document the runtime contract and compatibility boundary.

* refactor(web): advertise route patch support in heartbeats

Declare manual-route patch and typed readback support on the WebClient
heartbeat alongside existing client capabilities. Select route updates
and legacy readback from the current authenticated Session heartbeat,
including when reconciliation reuses an observed configuration.

Remove the unreleased GetConfig capability field and per-instance
capability wrapper. Keep only observed configuration in the existing
cache, and retain strict application confirmation after full Run.

Cover the client declaration, legacy default and capability changes
against the same cached configuration. Update the runtime contract.

---------

Co-authored-by: Codex <codex@users.noreply.github.com>
2026-10-16 00:47:43 +08:00
Fa 7588062f7e fix(wasi): initialize and control embedded runtime logging (#2655) 2026-10-15 20:37:32 +08:00
KKRainbow da6d721b97 test(credentials): wait for deterministic two-admin election (#2705)
The non-reusable credential test guessed an owner from three route
samples and repeatedly probed reachability with fixed stability sleeps.
Partial route synchronization could select a temporary owner, while the
extra waits pushed otherwise healthy runs past the 60-second CI limit.

Wait for both credential peers to connect, then require both admins to
route only to the lower peer ID selected by the owner election. After
stopping that peer, wait for both admins to route only to the survivor.
Verify reachability and rejection from both admins in each phase without
the redundant sampling loops. Keep the existing nextest timeout.
2026-10-15 10:02:45 +08:00
KKRainbow ec694f7cb8 fix(web): align central network initial-node protocols (#2704) 2026-10-15 01:30:06 +08:00
KKRainbow 8839b4b2f1 fix: avoid shared IPv4 miscompilation on Rust 1.95 (#2703)
Keep ipv4_inet out of line to prevent LLVM load combining from
zeroing the address and prefix when inlined into async add_ip.
This works around Windows named-NIC IPv4 setup failures on the
current release toolchain.

Link the upstream LLVM fix so the workaround can be removed once
all build toolchains include it.

Fixes #2696
2026-10-15 00:53:24 +08:00
KKRainbow 0ca2fde0fb ci: align nightly assets with version releases (#2699)
Publish GUI installers and Android APKs under their original names.
Use the built commit version in Core, Web and Magisk ZIP names, and
match the version-release archive layout while preserving executables.

Generate SHA256SUMS for all downloads and validate the packaged asset
manifest before publication. Cover native installers, archive layout,
checksums and invalid artifacts in the CI script tests.

Document versioned download URLs and remove the stable-URL claim.
2026-10-14 14:11:53 +08:00
KKRainbow d25ccd4c17 fix(windows): reuse numbered TUN names after complete shutdown (#2694)
* fix(windows): reuse stable TUN identities after complete shutdown

Wait for NIC forwarding and packet-drain tasks to release their handles
before stopping or replacing the interface. Stop the old static-IP NIC
before entering its recreation path. Store typed NIC state so shutdown
can await the owned tasks instead of only cancelling them on drop.

Use the instance UUID for the default Windows interface name and derive
a stable Wintun GUID from the device name. Keep explicit names and their
existing sharing behavior, while generated names remain dedicated. Keep
resolved names out of the input flags so recreation preserves that
choice. Pin rust-tun to the fix that joins its reader during session
drop.

Remove the global NetworkList cleanup that deleted profiles based only
on their names. Stable identities avoid creating a new profile for each
restart without guessing which existing records are safe to delete.

Add regression tests for receiver release and Windows instance restarts
with another dedicated or shared-NIC member still running.

Validation: formatting and workspace Clippy pass; 66 NIC, shared-NIC and
TUN runtime tests pass in the privileged rust container.

Fixes #2682 Related: #110

* fix(windows): allocate the first unused numbered TUN name

Use et_1, et_2 and subsequent vacant slots for default Windows NICs.
Reserve each name across threads and processes with a named kernel
object, retaining it through firewall cleanup and releasing it on exit.
Check the current device tree to skip disabled adapters without treating
removed adapters in the IP interface tables as occupied slots.

Keep GUIDs derived from names and preserve explicit NIC sharing. Cover
concurrent allocation, case-insensitive reservations and slot reuse.

* fix(windows): release idle TUN names across cached and service owners

Release a shared NIC name after its dispatcher has stopped and its
firewall rules have been removed, even when the registry caches the NIC.
Clear the old interface name so a later cache drop cannot remove rules
belonging to its replacement. Await dispatcher teardown on control
failure and timeout as well as on successful shutdown.

Grant only SYNCHRONIZE access to name reservations across accounts so
SYSTEM services and desktop processes can skip occupied slots.
Cover idle registry reuse, firewall ownership and failed-control cleanup
with regression tests.

* test(tun): use the renamed dispatcher task handle on Unix

Update the two Unix-only teardown tests to match the task field used
by explicit dispatcher shutdown.

* fix(windows): report firewall cleanup failures and verify all rules

Inspect all eight interface rules, including the ALL protocol pair.
Treat only a missing rule as absent and propagate other lookup errors.
Attempt every rule deletion and report failures with rule names instead
of silently returning success after a permission or service error.

Validate rule protocols, directions, profiles, interface binding and
allow settings on Windows. Cover repeated add/remove, unprivileged
failures, full cleanup and shared-NIC rule ownership across restarts.

Pin tun-easytier to merged v2 commit
5ba6be073e5b486b68c12f80fac5cfb50601f024 and update Cargo.lock. Its tree
matches the previously tested PR commit.
2026-10-14 13:43:35 +08:00
KKRainbow 15a3d43039 ci: wait for nightly builds before publishing the release (#2695)
GITHUB_TOKEN-dispatched builds do not trigger downstream workflow_run
jobs. The nightly release check ran before builds finished and then
exited successfully without publishing anything.

Track dispatch run IDs and wait for all missing components to succeed.
Retry failed jobs once, account for delayed rerun state, and fail on
terminal errors. Bound the build and wait job to three hours.

Pass the built commit to publication so later main commits do not
change the release target. Retain component deduplication and manual
build completion handlers, and document the nightly lifecycle.

Add regression coverage for pending builds, retries, terminal failures,
dispatch races, and main advancing before publication. All 34 Node tests
and actionlint for the three nightly workflows pass.
2026-10-14 09:57:54 +08:00
KKRainbow 76439ce562 docs: fix Ask DeepWiki badges in both READMEs (#2693) 2026-10-14 02:14:46 +08:00
KKRainbow 36b4ffcc74 ci: publish rolling nightly releases with build deduplication (#2692)
* ci: publish rolling main nightly builds and retry failed jobs

Keep one nightly prerelease with stable artifact filenames. Publish
only successful Core, GUI, Mobile and Test runs from the same main
commit, and prevent older runs from replacing a newer nightly.

Generate dated changes against the latest published version release,
including manual prereleases, in newest-first order. Link the fixed
nightly download page from both READMEs.

Retry failed full-build jobs once on main and develop, excluding
cancelled runs and attempts after the first.

Validated with 28 Node tests, actionlint, archive packaging checks,
and read-only selection of real GitHub builds and release notes.
2026-10-14 01:24:00 +08:00
KKRainbow 03078b4791 feat(dhcp): support automatic IPv6 address assignment (#2688)
Accept a boolean or an IPv6 CIDR in dhcpv6 and --dhcpv6, using
fd6b:8c2d:91e4::/64 when no initial subnet is supplied. Keep IPv4
configuration serialization compatible and DHCPv6 disabled by default.

Share the DHCP lifecycle and host update between both address families,
with typed allocation state, peer subnet selection, conflict recovery,
and bounded IPv6 probing. Preserve static addresses of the other family
and prevent static config patches from replacing DHCP-owned leases.

Carry IPv6 settings, leases, and events through management, web config,
and mobile VPN setup. Reuse the shared NIC registry and mobile address
list for IPv6-only and dual-stack operation without extra FD owners.

Cover config round trips, allocation and recovery, mobile
reconciliation, three-node IPv6 and dual-stack connectivity, and shared
TUN member exit. Validate with workspace clippy, Docker TUN tests,
frontend tests/builds, WASI and Android Rust checks, and Android VPN
Kotlin compilation.
2026-10-14 00:36:47 +08:00
KKRainbow 7c8ca07812 refactor(tun): consolidate shared NIC configuration and tests (#2690)
* refactor(tun): consolidate shared NIC configuration and tests

Separate member claims from OS deltas so mobile admission updates do
not compute unused diffs. Reuse effective configurations for desktop
address, route, and MTU updates, and share the platform claims paths.

Remove redundant runtime dispatch and test-only public accessors while
preserving registration cleanup and platform device creation ordering.

Move configuration and lifecycle tests into focused modules. Reuse
setup and table-driven cases, and verify route replacement order,
source hints, stale registrations, and failed cleanup through behavior.

Validation: shared NIC unit and Docker integration tests, mobile claim
selection, TUN and public IPv6 tests, full-workspace clippy, Android
arm64 library check, and formatting of the changed files.

* docs(tun): describe shared Ring dispatch lifecycle

Document direct member Ring polling, mobile descriptor replacement,
and cooperative scheduling in the shared dispatcher. The dispatcher
implementation is already provided by the base branch in #2689.

* test(core): remove completion race from timeout budget test

Use a pending future to exercise stage-specific timeout errors.
A delayed CI runner can observe the old 20 ms sleep and 1 ms timeout
as ready together. Tokio polls the inner future first, returning Ok
instead of the error expected by the test.

Keep the timeout assertion without a competing completion timer.
Production timeout behavior is unchanged.
2026-10-13 23:53:35 +08:00
KKRainbow 4d4b5ad8dc refactor(tun): consolidate NIC setup and DHCP host logic (#2691)
Select dedicated or shared NICs through one constructor, removing
redundant construction wrappers and unreachable setup error handling.

Share desktop and mobile DHCP cancellation, publication, and permit
handling while retaining their platform-specific NIC setup.

Build mobile VPN configuration once after member selection or attach,
and use CIDR formatting that preserves host prefixes.

Cover DHCP publication locking and cancelled address updates, and
retain explicit IPv4 /32 and IPv6 /128 projection assertions.
2026-10-13 23:52:24 +08:00
KKRainbow 0da58e68df refactor(tun): dispatch shared members through Ring endpoints (#2689) 2026-10-13 23:12:50 +08:00
KKRainbow 32dc7f1f40 feat: add shared virtual nic (#2352)
* Add shared virtual NIC core with per-member routing

Share a named TUN across instances while keeping unnamed devices
dedicated. Track member addresses, routes, and MTU, and reject
overlapping destinations except the common Magic DNS host route.

Dispatch packets between the physical TUN and member tunnels. Keep
IPv4 and ordinary IPv6 source translation for mobile wrong-source
traffic. Update platform configuration and runtime host integration,
with unit and network namespace coverage for routing and lifecycle.

Use smoltcp for packet parsing and checksum updates. Keep core CI
matrix jobs independent.

Reserve native inbound flows before source translations allocate their
wire tuples. Keep active reservations under table pressure and leave
forwarded subnet traffic outside the local flow table.

Complete interface claim updates on the shared runtime and retain member
registrations until the transaction commits. Keep address, route, and
MTU cleanup ordered after cancelled callers, including device
initialization.

Project mobile VPN addresses, routes, and DNS from admitted native
members. Prefer unchanged attached claims and report conflicting members
individually. Apply each member's complete initial claims before opening
the shared fd, so conflicts leave healthy members usable.

Cover inbound IPv4/IPv6 collisions, cancellation during native changes
and dispatcher acknowledgments, admission priority, and atomic mobile
claim rejection with regression tests.

Use scoped TestNetwork fixtures for shared TUN bridge setup and cleanup.
Resolve public IPv6 namespace aliases through the lab's actual names so
the new tests remain isolated under parallel upstream test runs.

* Configure one mobile VPN from running TUN instances

Use admitted native members to configure VpnService and attach its
single fd to the core shared TUN. Wait for attachment completion and
record the actual attached members before refreshing VPN state. Report
rejected members in the GUI and preserve the VPN for unchanged healthy
members.

Reconcile ordinary configuration notifications without invalidating an
arbitrary member. Invalidate attachments on instance lifecycle changes,
including restarts that reuse an instance ID. Forward public IPv6
changes alongside proxy route changes to refresh the platform VPN
configuration.

Exclude confirmed IPv4-less members from readiness gating while
retaining retries for unavailable status and pending addresses. Quick
Settings stops all running TUN members and restores that group on the
next start, skipping removed configurations and no_tun networks.

Keep the FFI set_tun_fd call scoped to one instance and report
attachment errors. Cover mobile configuration, member rejection and
replacement, attachment completion, collection notifications, IPv4-less
members, and tile stop/start restoration in GUI regression tests.

Validate with 36 mobile VPN tests, vue-tsc --noEmit, Android GUI cargo
check, workspace clippy, and shared TUN network namespace tests.

* fix(tun): preserve shared NIC ownership and failure invariants

Keep mobile address and route claims in dispatcher tables without
applying desktop interface configuration to synthetic TUN names.

Limit the Magic DNS overlap exemption to route claims and remove the
shared route retry that bypassed claim accounting after invalidation.

Invalidate and stop dispatchers on failed mobile TUN replacement or I/O.
Bound TUN writes so stalled devices release pending source updates and
member cleanup instead of retaining an unusable shared dispatcher.

Treat missing Darwin and Windows addresses and routes as successful
deletions while preserving permission and other configuration errors.

Add regressions for claim conflicts, mobile configuration, failed TUN
replacement, stalled writes, and platform deletion error handling.

* fix(tun): forward single-member shared NIC traffic unchanged

Deliver TUN packets directly to the sole attached member without
destination matching or creating source translations. Keep native flow
reservations so a later member cannot collide with existing connections,
and continue applying mappings created before other members leave.

Reuse flow preparation with the original source address. Preserve the
desktop subnet forwarding bypass, including when the flow table is full.
IPv4 fragments and IPv6 extension headers remain unchanged on flows that
need no translation.

Cover unknown sources and destinations, first and later IP fragments,
native flows established before another member joins, and retained
translations after returning to one member. Document the forwarding
behavior and retain rejection coverage for multiple attached members.

* fix(tun): preserve wildcard UDP replies on shared member ports

Mark flow reservations created by incoming traffic separately from
outgoing mappings. When a wildcard UDP socket replies with another
member's source address, reuse the incoming reservation after source
correction and preserve its service port and inbound destination.

Keep exact outgoing mappings and outgoing flow collision handling ahead
of this rule. Incoming UDP sessions take priority when packet tuples
cannot distinguish a reply from a new client using identical endpoints.

Cover IPv4 and IPv6 replies, checksum updates, repeated inbound traffic,
existing outgoing mappings, TCP collisions, and single-to-shared member
transitions with regression tests.

* test(tun): wait for IPv6 activation in mobile configuration test

Assert that the configured IPv6 address is registered before forwarding
starts, then wait up to five seconds for the kernel to make it usable.
Netlink acknowledges address creation before asynchronous IPv6
activation completes, so an immediate bind can fail under CI
parallelism.

Disable automatic link-local address generation in the test namespace to
keep startup IPv6 control traffic out of member-routing assertions.

Validation: the original test failed 40 of 320 runs at 32-way
parallelism. The updated test passed all 320 runs; workspace fmt and
clippy also pass.
2026-10-13 21:00:03 +08:00
fanyang 99fce327d5 fix(ohos): reject unsupported services and remove interface warnings (#2676) 2026-10-13 12:09:10 +08:00
fanyangandKKRainbow 1c6992d6cb fix(web): bind config listeners without route probes (#2660)
Construct config-server TCP and UDP listeners on IPv4 and IPv6
wildcard addresses. A default route is not needed for listening, so
remove the local-IP probes, URL-to-address wrapper, and optional
listener pairs. Keep WebSocket on its existing IPv4 address.

Propagate all native socket bind errors instead of ignoring IPv6
failures. Try address families independently and stop startup only
when no listener could bind, so one unavailable family does not hide
a working listener or create an unbound socket.

Log every successful listener with its actual bound address, including
the allocated port when the configured port is zero.

Add regressions for occupied ports and unavailable IPv6 source
addresses, failed listener registration, and valid ephemeral ports.

Fixes #2611

Co-authored-by: KKRainbow <5665404+KKRainbow@users.noreply.github.com>
2026-10-13 10:04:10 +08:00
KKRainbow 9bc1a0941f feat(dhcp): accept an IPv4 CIDR for the initial address subnet (#2686)
* feat(dhcp): accept an IPv4 CIDR for the initial address subnet

Accept booleans or an IPv4 CIDR in --dhcp, ET_DHCP, and TOML. Preserve
existing boolean behavior and reject invalid subnets and /31 or /32.

Carry the bootstrap subnet through management config conversions and
runtime snapshots. Keep peer-advertised subnets authoritative and use
10.126.126.0/24 when no custom bootstrap subnet is supplied.

Cover CLI overrides, config round trips, validation, address allocation,
and subnet precedence. Update English and Chinese CLI help.

* refactor(dhcp): select the bootstrap subnet during construction

Read the optional DHCP CIDR once when creating the service and pass it
to the existing allocator constructor. Restore reconciliation without
rewriting the bootstrap subnet on every round.

Initialize test configurations before constructing their DHCP services.
The PatchConfig API does not expose the DHCP policy; management changes
recreate the network instance.
2026-10-13 00:34:26 +08:00
KKRainbow c01e2baafa perf(core): cap shards in per-network and per-peer maps (#2685)
Public relays create many mostly empty maps for each foreign network.
DashMap defaults to 128 shards on a 24-CPU host, allocating about
1.26 GiB of shard arrays for 2,421 networks before peer/session state.

Cap these maps at eight shards and retain the four-shard default on
single-CPU hosts. Cache the shard count once and keep global indexes
on their existing default. Apply the same cap to peer connection maps
and route synchronization version maps.

Replace nested peer/network DashSets with HashSets protected by their
existing outer map or an entry mutex. Keep cross-index updates under
the manager lock. Use a plain HashMap for the local route graph index.

Validation: 245 peer/RPC tests and eight three-node integration tests
passed, along with workspace fmt and clippy with full features.
2026-10-13 00:18:49 +08:00
KKRainbow d31396f04c fix(hole-punch): connect UDP when STUN mapping differs (#2680)
* fix(hole-punch): connect UDP without reverse probe gating

Start cone UDP handshakes on the same punched socket without waiting
for reverse HolePunch packets. Keep outgoing probes while the existing
session layer retries SYN, allowing a reachable listener to reply to the
actual source when the STUN candidate uses a different mapping.

Register source observers on the listener's existing receive loop for
the active cone RPC. Send bounded batches to the signaled endpoint and
the first matching observed source, deduplicating equal endpoints.
Unregister observers on completion, cancellation, or listener shutdown.

Cover missing reverse probes, dropped SYN, late observed sources,
transaction isolation, and cleanup. Document socket and connectivity
ownership of observation and reply policy.

* fix(hole-punch): prepare source observation during listener selection

Carry an optional transaction ID in SelectPunchListener and echo it only
after registering the listener's existing UDP receive loop. Preserve the
first source arriving before SendPunchPacketCone, then transfer cleanup
ownership to that RPC without clearing the observed address.

Keep prepared and active registrations in the same transaction table.
Expire abandoned preparations after 30 seconds through listener cleanup;
active observers still unregister on completion or cancellation.
Preserve legacy requests without a transaction ID and tolerate older
responses without a registration acknowledgement.

Cover early packets, transaction ID zero, preparation expiry, active RPC
cleanup, legacy protobuf interoperability, and SYN retries against peers
with and without registration confirmation. Document the lifetime and
compatibility contract in the core architecture.

Validation: 820 core tests; workspace fmt and full-feature clippy
passed.
2026-10-12 23:19:46 +08:00
KKRainbow 4b4f0c3cb3 fix: restore thread network namespaces and stabilize tunnel tests (#2683) 2026-10-12 20:04:01 +08:00
KKRainbow 1649ec0013 ci: parallelize Linux tests and skip HAR packaging on PRs (#2678) 2026-10-12 09:09:52 +08:00
KKRainbow f8a45ccc20 ci: use lightweight PR checks and conditional nightly builds (#2677)
* ci: use lightweight PR checks and conditional nightly builds

Check three Core platforms, Linux GUI, and Android ARM64 on PRs and
ordinary branch pushes. Keep formatting, clippy, WASI, and all Linux
tests; reserve the per-feature sweep for full builds.

Run complete platform matrices on releases/** pushes and manual
dispatch. At 03:17 Asia/Shanghai, check existing main and develop
branches and dispatch only workflows whose last successful full build
used a different commit, retrying failed components on the next night.

Cache compilation outputs by target and build mode. Build only the GUI
frontend dependencies and reuse the dashboard artifact for Core builds.
Separate full-build concurrency so ordinary pushes cannot cancel them.

Keep the required result checks and propagate failures and
cancellations. Validate successful full-build origins and matching Core,
GUI, and Mobile commits before preparing release assets.

Validation: actionlint 1.7.12, 10 Node tests, read-only GitHub nightly
dry run, GUI frontend build, Linux GUI check, Android ARM64 check, and
Linux musl Core/Web checks including embedded dashboard assets.

* docs: describe CI behavior in English and Chinese

Document PR checks, nightly dispatch, manual builds, release validation,
artifacts, and independent workflows. Keep both versions aligned with
current workflow definitions.

* ci: decouple PR checks from Web dashboard builds

Run PR dashboard builds only when frontend sources, Core configuration
sources, protobuf schemas, or build configuration change. Keep embedded
Web builds in full mode and let ordinary Rust checks start without
frontend artifacts.

Upload dashboard artifacts only for full builds. Skip frontend setup for
ordinary lint runs and remove GUI/frontend setup from test archives.
Update both CI documents to match the new behavior.

Validation: actionlint; frontend dependency and workflow routing cases;
Linux Core cargo check and workspace clippy with frontend outputs
absent.
2026-10-12 00:13:12 +08:00
lightfirstandKKRainbow 7592951b80 fix(dataplane): route UDP by bound local endpoints (#2652)
Register one UDP flow at bind so sockets can receive from new peers
before their first send. Allocate and reserve ports within each instance
independently of TUN and Host sockets. Release the endpoint on close.
Search the ephemeral range once and preserve concurrent cursor progress.

Route local UDP fragments by the first fragment's bound endpoint. Buffer
earlier tails in a bounded shallow cache and forward original packets to
one recipient. Preserve cached ownership across binding changes, expire
idle entries, and clear state on address changes and runtime shutdown.
This avoids orphan fragments exhausting smoltcp's reassembly slots while
keeping original packet sizes for TUN and proxy consumers.

Randomize each new stack's seed to avoid replaying IP IDs on rebuild.
Document accepted residual collision-related UDP loss or downstream
delivery instead of adding fragment completion tracking.

Allow peer filters to return a batch, resuming each packet at the next
filter without repeating earlier filters. The single-packet path does
not allocate a batch queue. Keep ZCPacket and IpReassembler unchanged.

Cover endpoint ownership, ephemeral allocation, out-of-order fragments,
cache limits and expiry, binding changes, batch continuation, and large
UDP datagrams after more than 16 complete unrelated fragmented
datagrams.

Exercise two recreated sending stacks with controlled seeds to verify
that new fragments do not inherit an old downstream route.

Co-authored-by: KKRainbow <5665404+KKRainbow@users.noreply.github.com>
2026-10-11 01:35:33 +08:00
fanyang 83baead059 fix(runtime): restore per-instance native thread configuration (#2658)
Restore per-instance Tokio executors for standard native instances,
honoring multi_thread and multi_thread_count with a two-worker minimum
in multi-thread mode. Preserve external execution for compact hosts.

Run construction, lifecycle operations, FFI data-plane tasks, and
hot-reloaded port forwards on the selected instance executor. Keep
shutdown cleanup running when its caller is cancelled, and release
owned executors without blocking their workers on runtime teardown.

Add runtime ownership, cancellation, cross-runtime connectivity, and
hot-reload regression coverage, and document the execution model.

Validation: all 45 PR checks passed, including linting, integration
tests, and cross-platform builds.
2026-10-11 01:17:32 +08:00
Luna YaoandClaude Code 250c2ad8d6 refactor(config): preserve explicit flags with FlagsPatch and derive CLI arguments from schema (#2585)
This PR refactors EasyTier's configuration flags system across protobuf definitions, TOML/API persistence, runtime reconciliation, and the CLI.

It introduces a synthesized FlagsPatch protobuf message with optionize and pbjson support to track field presence reliably, replaces manual CLI flag boilerplate with dynamic schema-driven clap argument generation, and
establishes Flags::resolve(patch) as the single source of truth for runtime defaults.

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-10-10 22:11:08 +08:00
fanyangandCopilot Autofix powered by AI 018750a785 fix: replace deprecated atomic fetch_update calls (#2665)
* fix: replace deprecated atomic fetch_update calls

Use try_update, available since the workspace's Rust 1.95 minimum, for all standard-library atomic update calls. Preserve closure behavior and memory ordering, and leave arc-swap's unrelated API unchanged.

Cover smoltcp port allocation's previous-value return and wrap boundary.

Fixes #2614

* Add unused_async allowance for tokio test

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-10-10 21:49:43 +08:00
fanyang 24afb4ecb4 fix(cli): warn about ignored TOML logging configuration (#2661)
* fix(cli): warn about ignored TOML logging configuration

Keep logging process-wide and direct users to CLI options or environment variables when legacy logging sections are present. Emit diagnostics for files, config directories, stdin, and config validation.

Refs #984

* fix(test): use relocated core binary in nextest archives

Resolve the runtime NEXTEST_BIN_EXE path before clearing the child environment, with a compile-time fallback for cargo test. This avoids using the build runner's binary path after extracting an archive on another runner.
2026-10-10 12:35:15 +08:00
fanyang d96eaa668b feat(cli): show direct peers using temporary credentials (#2663)
Add credential peers with table and JSON output while preserving credential list.
Match authenticated remote credential keys to stored fingerprints and virtual
route addresses. Keep instance results isolated and exclude unproven relay users.
Enable SHA-256 for CLI-only builds and add regression and multi-instance tests.
Accept omitted protobuf default lists in the existing multi-instance test.

Refs #2529
2026-10-10 12:33:47 +08:00
fanyang bd06395573 feat: support cargo-binstall for prebuilt CLI tools (#2659)
Map the existing release ZIP names and binary paths through package metadata, including ARM hard-float, RISC-V, Windows ARM64, and FreeBSD naming exceptions. Document repository-based installation and Windows sidecar requirements.

Fixes #365
2026-10-10 10:17:08 +08:00
刚刚and225284228a-droid db289e87bb fix(web): default virtual subnet masks to /24 (#2646)
Co-authored-by: 225284228a-droid <239500008+225284228a-droid@users.noreply.github.com>
2026-10-09 23:05:14 +08:00
刚刚and225284228a-droid 2583d867c3 fix(web): keep refresh buttons steady during polling (#2647)
Co-authored-by: 225284228a-droid <239500008+225284228a-droid@users.noreply.github.com>
2026-10-09 23:04:22 +08:00
fanyang 7fdfe90291 feat(cli): support configurable local state directory (#2654)
Add --state-dir and ET_STATE_DIR for persisted web-client machine IDs.

Fixes #2340
2026-10-09 21:23:02 +08:00
KKRainbow 7af7bdc16a build: consolidate dependencies and select rustls ring explicitly (#2648)
* build(core): consolidate crypto dependencies and trim features

Align AES-GCM, ChaCha20Poly1305, HMAC, SHA2 and HKDF with the versions
already required by Snow and STUN. Align base64 with pbjson, and explain
the coordinated upgrade constraints beside the manifest entries. Adapt
AEAD and HMAC calls without changing packet or key formats, and pin the
WireGuard client key derivation with an independent HKDF vector.

Limit Snow to the Noise algorithms used by the core. Use crossbeam-utils
directly, keep the futures executor in tests, and remove UUID fast-rng
from the core while retaining existing features in native consumers.
Enable browser entropy and certificate time for the rustls backend.

Core default normal/build dependencies fall from 255 to 224 packages;
duplicated package names fall from 24 to 5 against upstream 4837468d.

Validation: Docker tests pass: 970 core, 33 proto (2 ignored), 5
WireGuard, and TCP/UDP three-node encrypted relays. Clippy passes with
warnings denied for core, proto, native and web targets. Browser
default/ChaCha20, WASI and minimal native compile checks pass. Workspace
formatting passes.

* fix(tls): select ring explicitly across application entry points

Building easytier and easytier-web together enabled both ring and aws-lc
through reqwest 0.13. HTTPS endpoint discovery used rustls automatic
provider selection and could panic before sending a ClientHello.

Use reqwest rustls-no-provider to share ring, and explicitly install the
process default before starting CLI, GUI and web services. Initialize it
for standalone webhook construction too, retaining any provider already
selected by the host.

Pass ring directly to HTTPS discovery and WebSocket TLS builders so
library use is independent of application initialization order. Keep
existing certificate verification, SNI and protocol settings.

Add a duplex-stream regression that reproduces the original panic with
both backends enabled, and run it in CI. Explain provider selection and
feature-unification constraints next to the relevant code.

Validation: the new regression fails before the fix and passes after it.
Docker tests pass: 10 discovery, 13 webhook, 2 WebSocket, WSS three-node
AES-GCM relay, and WSS credential connectivity. Clippy with warnings
denied and fmt pass. GUI, browser, WASI, minimal native, endpoint-only
and WebSocket-only compile checks pass. Linux and Windows release
dependency trees contain only the ring runtime backend.

Fixes #2607

* ci: remove the separate rustls backend regression step

Keep the HTTPS discovery regression in the existing test archive and
runner. Avoid an extra core test build solely to enable both rustls
backends; that combination was verified locally before the TLS fix.

* build(web): share reqwest 0.13 with the OIDC client

Disable the reqwest 0.12 client bundled with openidconnect/oauth2 and
use the official oauth2-reqwest adapter around the workspace reqwest
0.13 client. Pin the pre-release adapter version until its API
stabilizes.

Keep the existing redirect policy and 30-second timeout. Initialize the
ring provider when constructing OIDC configuration, including outside
the web application entry point.

Exercise discovery, JWKS fetching, token success and OAuth error
responses against a local mock provider. Verify redirects are not
followed. Refresh Cargo.lock to remove reqwest 0.12 without unrelated
upgrades.

Validation: Docker OIDC tests (2) and webhook tests (13) pass. Clippy
with warnings denied for native/core/proto/web all targets and full
features passes, as does workspace formatting. The default core/web
dependency graph has one reqwest version and 45 multi-version names
instead of 46; the TLS runtime still selects ring only.
2026-10-09 10:22:32 +08:00
LighteningShadow 983afda177 fix(gui): declare pkexec dependency for deb package (#2635)
The GUI shells out to pkexec for privilege elevation on Linux
(elevate/linux.rs). Since Debian 12 / Ubuntu 24.04, pkexec is shipped
as a standalone binary package split from policykit-1, so it is not
guaranteed to be installed. On such systems (e.g. a minimal Debian 13
desktop) installing the official deb succeeds but launching the GUI
panics with:

  Failed to run elevated command: No such file or directory (os error 2)

Add 'pkexec | policykit-1' to the deb bundle dependencies. The
alternation keeps the package installable on older releases where
pkexec still lives inside policykit-1 (e.g. Ubuntu 22.04), while
tauri-bundler continues to append the auto-detected webkit2gtk/gtk
dependencies.
2026-10-09 00:02:37 +08:00
刚刚 4837468d43 fix(gui/web): 修复丢包率和 NAT 类型显示 (#2643) 2026-10-08 00:51:06 +08:00
Luna Yao eb32d2a930 fix(utils): fix some bugs in BufPoolWriter/BufList (#2634)
fix bugs introduced by feat(buf): add BufPool, BufMargins, and BufList for zero-copy packet buffering #2625
cf. perf(quic-proxy): use BufPool and BufMargins in QuicSocket #2626 (comment)
2026-10-07 21:58:46 +08:00
KKRainbow 728ba94b50 Link easytier-mini downloads and fix UDP admission test ordering (#2638) 2026-10-07 01:03:35 +08:00
249 changed files with 25708 additions and 5014 deletions
+25
View File
@@ -3,3 +3,28 @@ slow-timeout = { period = "60s", terminate-after = 1 }
[profile.default.junit]
path = "junit.xml"
[profile.ci]
test-threads = 32
[test-groups.subnet-proxy]
max-threads = 8
[[profile.default.overrides]]
filter = 'test(tests::three_node::subnet_proxy_three_node_test)'
test-group = 'subnet-proxy'
# These tests modify or inspect host-wide interfaces, routes, or BPF state.
# They must exclude all other tests, including the isolated network fixtures.
# Run exclusive tests first so the parallel pool does not repeatedly drain.
[[profile.default.overrides]]
filter = 'test(tests::upnp_test) | test(common::ifcfg::netlink::tests) | test(instance::public_ipv6_provider::platform::tests) | test(instance::dns_server::tests) | test(socket::fake_tcp::netfilter::linux_bpf::tests)'
threads-required = 'num-test-threads'
priority = 100
# These assertions require a reply within 100 ms, on the first ping, or a
# minimum measured throughput. Competing tests can delay packet processing.
[[profile.default.overrides]]
filter = 'test(tests::three_node::p2p_only_test) | test(tests::three_node::relay_peer_e2e_encryption) | test(tests::three_node::whitelist_test) | test(relay_bps_limit_test) | test(instance_recv_bps_limit_test)'
threads-required = 'num-test-threads'
priority = 100
+82
View File
@@ -0,0 +1,82 @@
# CI
[中文](CI.zh-CN.md)
| Trigger | Core | GUI | Mobile |
| --- | --- | --- | --- |
| PR targeting `main`/`develop`, or push to either branch | Core/Web `cargo check`: Linux x64, Windows x64, macOS ARM64 | Linux frontend build and Rust check | Android ARM64 frontend build and Rust check |
| Push to `releases/**`, or manual dispatch | 16 targets and Magisk artifacts | Installers for 7 targets | APKs for 4 architectures |
Test runs CI script tests, fmt, clippy, WASI, Cargo.lock validation, and
Linux tests for `easytier`/`easytier-core`, including `three_node`. Full mode
also checks each `easytier` feature except `macos-ne`.
Linux tests run on one runner with 32 concurrent nextest processes. Network
integration tests use separate namespaces and bridges for each test. Subnet
proxy cases run at most eight at a time. Tests that use host-wide network state,
measure bandwidth, or make time-sensitive connectivity assertions run first,
exclusively, before the parallel tests.
These four workflows skip build/test jobs for draft PRs. Path and duplicate
content checks can skip ordinary builds and Rust jobs. The result jobs are
`core-result`, `gui-result`, `mobile-result`, and `test`.
PR dashboard builds run only for frontend, Core configuration, schema, or
build configuration changes. Ordinary branch pushes build the dashboard.
Core checks run independently; Web embedding is compiled only in full mode.
Test skips the frontend build in ordinary mode; its test archive builds
only Rust packages.
PR runs can upload the test archive for reuse within CI. The dashboard is
uploaded only in full mode.
Core binaries, GUI installers, APKs, and Magisk artifacts require full mode.
Compilation outputs are cached. Ordinary pushes do not cancel full builds.
## Nightly
Scheduled at 03:17 Asia/Shanghai, or started manually on `main`. Check Core,
GUI, Mobile, and Test separately. Compare main HEAD with that workflow's latest
successful `workflow_dispatch` run: skip matching commits; dispatch otherwise.
Core's `sign_windows` is set to `false` for nightly builds.
Nightly waits for the dispatched runs, retrying failed jobs once. A second
failure or a cancelled run fails Nightly and prevents publication. The build
and wait job has a three-hour timeout. Once all four components succeed at the
same commit, Nightly updates the rolling `nightly` prerelease using that commit,
even if main advances while builds are running. If main changes between the
dispatch requests and the builds target different commits, Nightly fails and
must be started again.
Builds dispatched with `GITHUB_TOKEN` do not trigger downstream `workflow_run`
jobs, so scheduled publication and retries run directly in Nightly. Completion
events still handle full main builds dispatched manually by a maintainer.
Nightly Release can also be started manually to publish existing main HEAD
builds without rebuilding them.
Nightly uses the same asset names and archive layout as version releases.
Core, Web, and Magisk ZIP names include the built commit's version (for example,
`easytier-linux-x86_64-v2.7.0.zip`). GUI installers and Android APKs retain their
original filenames without an extra ZIP wrapper. `SHA256SUMS` covers every
download. Asset URLs can change when the project version changes; use the
fixed `nightly` release page to find the current files.
## Manual builds and releases
Repository Write access is required. In Actions, run Core, GUI, and Mobile
separately on the PR branch to obtain full artifacts. The branch must contain
these workflow definitions. Fork PRs need a temporary branch in this repository.
Manual Core builds default to SignPath test signing (`sign_windows: true`).
Full macOS GUI builds are signed and notarized.
Release is a separate manual workflow. Its Core/GUI/Mobile run IDs must refer
to successful full builds from their respective workflows at the same commit.
It downloads their artifacts and creates a draft release.
## Other workflows
- OHOS runs independently. Relevant PRs run bridge tests and check the bindings
for the host and ARM64 OHOS. Non-PR runs build and upload HAR artifacts;
publishing and downstream builds retain their existing main/manual conditions.
- Go, JavaScript Hosts, and Nix use their own path filters.
- Docker is manual; it downloads a specified Core run's artifacts and pushes images.
+73
View File
@@ -0,0 +1,73 @@
# CI
[English](CI.md)
| 触发方式 | Core | GUI | Mobile |
| --- | --- | --- | --- |
| 目标为 `main`/`develop` 的 PR,或 push 到这两个分支 | Linux x64、Windows x64、macOS ARM64 的 Core/Web `cargo check` | Linux 前端构建和 Rust 检查 | Android ARM64 前端构建和 Rust 检查 |
| Push 到 `releases/**`,或手动触发 | 16 个目标和 Magisk 产物 | 7 个目标的安装包 | 4 个架构的 APK |
Test 运行 CI 脚本测试、fmt、clippy、WASI、Cargo.lock 检查,以及
`easytier`/`easytier-core` 的 Linux 测试(含 `three_node`)。全量模式还逐个
检查 `easytier` 的 feature,排除 `macos-ne`。
Linux 测试使用一个 runner,nextest 总并发度为 32。网络集成测试为每个测试
创建独立的 namespace 和网桥。子网代理用例最多同时运行八个;带宽测试、
使用宿主机全局网络状态的测试和时间敏感的连通性测试优先独占
执行,完成后再运行并发测试。
这四个 workflow 的构建和测试任务跳过 draft PR。普通构建和 Rust 任务可因
无关路径或重复内容而跳过。汇总检查为 `core-result`、`gui-result`、
`mobile-result` 和 `test`。
PR 仅在前端、Core 配置源码、schema 或构建配置变化时构建 Web 前端。
普通分支 push 仍构建 Web 前端。Core 检查独立运行;Web 的嵌入模式仅在
全量构建中编译。
Test 在普通模式下跳过前端构建,测试归档仅构建 Rust 包。
PR 可上传供 CI 内部复用的测试归档。Web 前端仅在全量模式上传。
Core 二进制、GUI 安装包、APK 和 Magisk 产物仅在全量模式上传。编译产物
启用缓存。普通 push 不会取消全量构建。
## 每日构建
定时为北京时间 03:17,也可以在 `main` 上手动运行。分别检查 Core、GUI、
Mobile、Test:比较 main HEAD 与该 workflow 最近一次成功的
`workflow_dispatch` 提交,相同则跳过,不同或无成功记录则触发该 workflow。
每日 Core 构建设置 `sign_windows: false`。
Nightly 等待已触发的构建结束,失败任务自动重试一次。再次失败或取消会使
Nightly 失败并阻止发布,构建和等待任务的超时为三小时。四个组件在同一提交
上全部成功后,Nightly 使用该提交更新固定的 `nightly` 预发布版本,即使等待
期间 main 已有新提交。如果 main 在各次触发请求之间变化,导致构建提交不
一致,Nightly 会失败,需要重新运行。
`GITHUB_TOKEN` 触发的构建不会触发下游 `workflow_run` 任务,因此定时构建的
发布和重试直接由 Nightly 执行。维护者手动触发的 main 全量构建仍由完成事件
处理。也可手动运行 Nightly Release,使用 main HEAD 的现有成功构建直接发布。
Nightly 的产物名称和压缩包目录结构与版本发布保持一致。Core、Web 和 Magisk
ZIP 文件名包含构建提交中的版本号,例如 `easytier-linux-x86_64-v2.7.0.zip`。
GUI 安装包和 Android APK 保留原始文件名,不再套一层 ZIP。`SHA256SUMS`
包含所有下载文件的校验值。项目版本变化时产物下载地址也会变化,可通过固定的
`nightly` 发布页面查找当前文件。
## 手动构建与发布
需要仓库 Write 权限。在 Actions 分别运行 Core、GUI、Mobile,选择 PR 分支
即可生成全量产物。该分支必须包含这些 workflow 定义。Fork PR 需要先在本
仓库建立临时分支。
手动 Core 构建默认开启 SignPath 测试签名(`sign_windows: true`)。
全量 macOS GUI 构建会签名和公证。
Release 是独立的手动 workflow。输入的 Core/GUI/Mobile run ID 必须分别来自
正确的 workflow、全量构建成功,且提交相同。它下载这些产物并创建 draft
release。
## 其他 workflow
- OHOS 独立运行。相关 PR 运行桥接单测,并检查宿主平台和 ARM64 OHOS 的绑定。
非 PR 构建生成并上传 HAR;发布和下游构建仍按原有主线/手动触发条件执行。
- Go、JavaScript Hosts、Nix 使用各自的路径过滤。
- Docker 手动触发,下载指定 Core 构建的产物并推送镜像。
+204
View File
@@ -0,0 +1,204 @@
const assert = require('node:assert/strict');
const { test } = require('node:test');
const dispatch = require('./nightly-build.js');
const validate = require('./validate-release-builds.js');
const context = { repo: { owner: 'EasyTier', repo: 'EasyTier' } };
const core = { info() {} };
const workflows = ['core.yml', 'gui.yml', 'mobile.yml', 'test.yml'];
function nightly(branches, successfulRuns = {}) {
const f = { dispatched: [], retried: [], responses: {}, inspected: [], sleeps: 0 };
const github = {
paginate: async () => branches,
rest: {
repos: { listBranches() {} },
actions: {
async listWorkflowRuns(request) {
assert.equal(request.event, 'workflow_dispatch');
assert.equal(request.status, 'success');
const sha = successfulRuns[`${request.branch}/${request.workflow_id}`];
return { data: { workflow_runs: sha ? [{ head_sha: sha }] : [] } };
},
async createWorkflowDispatch(request) {
assert.equal(request.headers['X-GitHub-Api-Version'], '2026-03-10');
f.dispatched.push(request);
return { data: { workflow_run_id: f.dispatched.length, html_url: `run/${f.dispatched.length}` } };
},
async getWorkflowRun({ run_id }) {
const workflow = f.dispatched[run_id - 1].workflow_id;
f.inspected.push({ workflow, dispatched: f.dispatched.length });
return { data: {
head_sha: branches.find(branch => branch.name === 'main').commit.sha,
status: 'completed', conclusion: 'success', run_attempt: 1,
...f.responses[workflow]?.shift(),
} };
},
async reRunWorkflowFailedJobs({ run_id }) {
f.retried.push(run_id);
},
},
},
};
f.run = () => dispatch({ github, context, core, sleep: async delay => {
assert.equal(delay, 60_000);
assert.ok(++f.sleeps < 10, 'nightly should eventually finish');
} });
return f;
}
test('first nightly builds every workflow on an existing main branch', async () => {
const f = nightly([{ name: 'main', commit: { sha: 'new' } }]);
assert.equal(await f.run(), 'new');
assert.deepEqual(f.dispatched.map(run => run.workflow_id), workflows);
assert.ok(f.dispatched.every(run => run.ref === 'main'));
assert.deepEqual(f.dispatched[0].inputs, { sign_windows: false });
assert.ok(f.dispatched.slice(1).every(run => !run.inputs));
assert.deepEqual(f.inspected.map(run => run.workflow), workflows);
assert.ok(f.inspected.every(run => run.dispatched === 4));
});
test('unchanged branches skip builds and unmonitored branches are ignored', async () => {
const successfulRuns = Object.fromEntries(workflows.map(workflow => [`main/${workflow}`, 'same']));
const f = nightly([
{ name: 'main', commit: { sha: 'same' } },
{ name: 'feature', commit: { sha: 'new' } },
], successfulRuns);
assert.equal(await f.run(), 'same');
assert.deepEqual(f.dispatched, []);
assert.equal(f.sleeps, 0);
});
test('nightly retries a failed component using its last successful commit', async () => {
const successfulRuns = Object.fromEntries(workflows.map(workflow => [`main/${workflow}`, 'new']));
successfulRuns['main/gui.yml'] = 'old';
const f = nightly([{ name: 'main', commit: { sha: 'new' } }], successfulRuns);
assert.equal(await f.run(), 'new');
assert.deepEqual(f.dispatched.map(run => run.workflow_id), ['gui.yml']);
});
test('nightly only builds main even when other branches exist', async () => {
const f = nightly([
{ name: 'main', commit: { sha: 'new' } },
{ name: 'develop', commit: { sha: 'new' } },
{ name: 'feature', commit: { sha: 'new' } },
]);
await f.run();
assert.deepEqual(f.dispatched.map(run => run.workflow_id), workflows);
assert.ok(f.dispatched.every(run => run.ref === 'main'));
});
test('nightly waits for every dispatched build before allowing publication', async () => {
const f = nightly([{ name: 'main', commit: { sha: 'new' } }]);
f.responses['core.yml'] = [{ status: 'queued' }, { status: 'in_progress' }, {}];
f.responses['test.yml'] = [{ status: 'in_progress' }, {}];
assert.equal(await f.run(), 'new');
assert.equal(f.sleeps, 2);
assert.deepEqual(f.inspected.map(run => run.workflow), [
...workflows, 'core.yml', 'test.yml', 'core.yml',
]);
});
test('nightly retries failed jobs once and waits for the new attempt', async () => {
const f = nightly([{ name: 'main', commit: { sha: 'new' } }]);
f.responses['gui.yml'] = [
{ conclusion: 'failure' },
{ conclusion: 'failure' }, // The API still returns the first attempt.
{ status: 'in_progress', run_attempt: 2 },
{ run_attempt: 2 },
];
assert.equal(await f.run(), 'new');
assert.deepEqual(f.retried, [2]);
assert.equal(f.sleeps, 3);
});
test('nightly fails instead of publishing if a retried build fails again', async () => {
const f = nightly([{ name: 'main', commit: { sha: 'new' } }]);
f.responses['mobile.yml'] = [{ conclusion: 'failure' }, { conclusion: 'failure', run_attempt: 2 }];
await assert.rejects(f.run(), /mobile.yml: run 3 finished with failure/);
assert.deepEqual(f.retried, [3]);
});
test('nightly does not retry cancelled or timed-out builds', async () => {
for (const conclusion of ['cancelled', 'timed_out']) {
const f = nightly([{ name: 'main', commit: { sha: 'new' } }]);
f.responses['test.yml'] = [{ conclusion }];
await assert.rejects(f.run(), new RegExp(`test.yml: run 4 finished with ${conclusion}`));
assert.deepEqual(f.retried, []);
}
});
test('nightly rejects a dispatch that races with a new main commit', async () => {
const f = nightly([{ name: 'main', commit: { sha: 'new' } }]);
f.responses['gui.yml'] = [{ head_sha: 'newer', status: 'queued' }];
await assert.rejects(f.run(), /main changed while dispatching/);
assert.deepEqual(f.retried, []);
});
process.env.CORE_RUN_ID = '1';
process.env.GUI_RUN_ID = '2';
process.env.MOBILE_RUN_ID = '3';
function releaseRuns() {
return ['core.yml', 'gui.yml', 'mobile.yml'].map(workflow => ({
path: `.github/workflows/${workflow}`,
conclusion: 'success',
event: 'workflow_dispatch',
head_branch: 'main',
head_sha: 'same',
}));
}
async function release(runs) {
const github = {
rest: {
actions: {
async getWorkflowRun({ run_id }) {
return { data: runs[run_id - 1] };
},
},
},
};
return validate({ github, context, core });
}
test('release accepts successful full builds from one commit', async () => {
await release(releaseRuns());
});
test('release accepts full builds triggered by a release branch push', async () => {
const runs = releaseRuns();
runs[0].event = 'push';
runs[0].head_branch = 'releases/2.7';
runs[1].event = 'push';
runs[1].head_branch = 'releases/2.7';
await release(runs);
});
test('release rejects PR builds and regular branch pushes', async () => {
for (const event of ['pull_request', 'push']) {
const runs = releaseRuns();
runs[0].event = event;
await assert.rejects(release(runs), /successful full build/);
}
});
test('release rejects failed and cancelled builds', async () => {
for (const conclusion of ['failure', 'cancelled']) {
const runs = releaseRuns();
runs[1].conclusion = conclusion;
await assert.rejects(release(runs), /successful full build/);
}
});
test('release rejects run IDs from the wrong workflow', async () => {
const runs = releaseRuns();
runs[1].path = runs[0].path;
await assert.rejects(release(runs), /successful full build/);
});
test('release rejects builds from different commits', async () => {
const runs = releaseRuns();
runs[2].head_sha = 'different';
await assert.rejects(release(runs), /same commit/);
});
+70
View File
@@ -0,0 +1,70 @@
const { setTimeout } = require('node:timers/promises');
module.exports = async ({ github, context, core, sleep = setTimeout }) => {
const repository = context.repo;
const branches = await github.paginate(github.rest.repos.listBranches, {
...repository,
per_page: 100,
});
for (const branch of branches.filter(({ name }) => name === 'main')) {
const pending = new Map();
for (const workflow of ['core.yml', 'gui.yml', 'mobile.yml', 'test.yml']) {
const { data } = await github.rest.actions.listWorkflowRuns({
...repository,
workflow_id: workflow,
branch: branch.name,
event: 'workflow_dispatch',
status: 'success',
per_page: 1,
});
if (data.workflow_runs[0]?.head_sha === branch.commit.sha) {
core.info(`${workflow}: ${branch.name} is already built successfully.`);
continue;
}
const { data: run } = await github.rest.actions.createWorkflowDispatch({
...repository,
workflow_id: workflow,
ref: branch.name,
// This API version returns the dispatched run ID.
headers: { 'X-GitHub-Api-Version': '2026-03-10' },
...(workflow === 'core.yml' ? { inputs: { sign_windows: false } } : {}),
});
pending.set(workflow, { id: run.workflow_run_id, attempt: 1 });
core.info(`${workflow}: dispatched ${run.html_url} for ${branch.name}.`);
}
// GITHUB_TOKEN dispatches do not trigger downstream workflow_run jobs.
// Wait here so publication and retries do not depend on those events.
while (pending.size) {
for (const [workflow, build] of pending) {
const { data: run } = await github.rest.actions.getWorkflowRun({
...repository, run_id: build.id,
});
if (run.head_sha !== branch.commit.sha) {
throw new Error(`${workflow}: main changed while dispatching; run Nightly again.`);
}
// A rerun request can return before the new attempt becomes visible.
if (run.run_attempt < build.attempt || run.status !== 'completed') continue;
if (run.conclusion === 'success') {
core.info(`${workflow}: run ${build.id} succeeded.`);
pending.delete(workflow);
} else if (run.conclusion === 'failure' && run.run_attempt === 1) {
await github.rest.actions.reRunWorkflowFailedJobs({
...repository, run_id: build.id,
});
build.attempt = 2;
core.info(`${workflow}: retrying failed jobs in run ${build.id}.`);
} else {
throw new Error(`${workflow}: run ${build.id} finished with ${run.conclusion}.`);
}
}
if (pending.size) {
core.info(`Waiting for ${[...pending.keys()].join(', ')} at ${branch.commit.sha}.`);
await sleep(60_000);
}
}
return branch.commit.sha;
}
};
+241
View File
@@ -0,0 +1,241 @@
const fs = require('node:fs/promises');
const path = require('node:path');
const { execFile } = require('node:child_process');
const { createHash } = require('node:crypto');
const { promisify } = require('node:util');
const exec = promisify(execFile);
const workflows = ['core', 'gui', 'mobile', 'test'];
function releaseNotes(repository, baseline, sha, commits, runs) {
const url = `https://github.com/${repository.owner}/${repository.repo}`;
const changes = [...commits]
.sort((a, b) => Date.parse(b.commit.committer.date) - Date.parse(a.commit.committer.date))
.map(commit => {
const date = new Date(commit.commit.committer.date).toISOString().slice(0, 10);
const subject = commit.commit.message.split('\n')[0];
return `- ${date} — ${subject} ([${commit.sha.slice(0, 7)}](${commit.html_url}))`;
});
return [
'# EasyTier Nightly',
'',
'Development builds from `main`. This prerelease is updated in place. Asset names follow version releases.',
'',
`Commit: [${sha}](${url}/commit/${sha})`,
`Builds: ${runs.map(run => `[${run.name}](${run.html_url})`).join(' · ')}`,
'',
`## Changes since [${baseline.tag_name}](${baseline.html_url})`,
'',
'Dates are commit dates in UTC, newest first.',
'',
...changes,
...(changes.length ? [] : ['No changes since this version.']),
'',
`[Full changelog](${url}/compare/${baseline.tag_name}...${sha})`,
'',
].join('\n');
}
async function prepare({ github, context, core, sha }) {
const repository = context.repo;
const { data: branch } = await github.rest.repos.getBranch({ ...repository, branch: 'main' });
sha ||= context.payload.workflow_run?.head_sha || branch.commit.sha;
const { data: ancestry } = await github.rest.repos.compareCommitsWithBasehead({
...repository, basehead: `${sha}...${branch.commit.sha}`, per_page: 1,
});
if (!['ahead', 'identical'].includes(ancestry.status)) {
core.info(`${sha} is no longer on main.`);
return null;
}
// Listing releases also finds an unfinished draft from the first upload.
const releases = await github.paginate(github.rest.repos.listReleases, {
...repository, per_page: 100,
});
const release = releases.find(item => item.tag_name === 'nightly');
if (release?.immutable) throw new Error('The nightly release must allow asset and tag updates.');
if (release && !release.draft) {
const { data: comparison } = await github.rest.repos.compareCommitsWithBasehead({
...repository, basehead: `nightly...${sha}`, per_page: 1,
});
if (!['ahead', 'identical'].includes(comparison.status)) {
core.info('A newer nightly is already available; skipping this older build.');
return null;
}
if (comparison.status === 'identical' && release.body?.includes(`Commit: [${sha}]`)) {
core.info(`${sha} is already published.`);
return null;
}
}
const runs = [];
const artifacts = {};
for (const workflow of workflows) {
const { data } = await github.rest.actions.listWorkflowRuns({
...repository,
workflow_id: `${workflow}.yml`,
branch: 'main',
head_sha: sha,
event: 'workflow_dispatch',
status: 'success',
per_page: 1,
});
const run = data.workflow_runs[0];
if (!run || run.head_sha !== sha || run.head_branch !== 'main' ||
run.event !== 'workflow_dispatch' || run.conclusion !== 'success') {
core.info(`Waiting for a successful full ${workflow} build at ${sha}.`);
return null;
}
runs.push(run);
if (workflow === 'test') continue;
const uploaded = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
...repository, run_id: run.id, per_page: 100,
});
// Reruns can retain same-name artifacts from earlier attempts. Select the
// newest ID, matching download-artifact's latest:true lookup.
const byName = new Map();
for (const artifact of uploaded) {
if (!/^(easytier-[\w.-]+|Easytier-Magisk)$/.test(artifact.name)) continue;
if (!byName.has(artifact.name) || artifact.id > byName.get(artifact.name).id) {
byName.set(artifact.name, artifact);
}
}
const files = [...byName.values()];
if (!files.length || files.some(artifact => artifact.expired)) {
throw new Error(`${workflow}: build artifacts are missing or expired.`);
}
artifacts[workflow] = files.map(artifact => ({ id: artifact.id, name: artifact.name }));
}
// Version releases may be published by a maintainer from a bot-created draft,
// and may themselves be prereleases. Neither author nor /releases/latest
// identifies the last manually published version reliably.
const baseline = releases.filter(item => !item.draft && /^v\d+\.\d+\.\d+(?:[-+].*)?$/.test(item.tag_name))
.sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at))[0];
if (!baseline) throw new Error('No published version release found for the nightly changelog.');
const commits = await github.paginate(github.rest.repos.compareCommitsWithBasehead, {
...repository, basehead: `${baseline.tag_name}...${sha}`, per_page: 100,
}, response => response.data.commits);
const { data: config } = await github.rest.repos.getContent({
...repository, path: 'easytier-gui/src-tauri/tauri.conf.json', ref: sha,
});
const { version } = JSON.parse(Buffer.from(config.content, 'base64').toString('utf8'));
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) {
throw new Error('The build must declare a valid release version.');
}
return {
sha,
version: `v${version}`,
release_id: release?.id,
runs: Object.fromEntries(workflows.map((workflow, index) => [workflow, runs[index].id])),
artifacts,
body: releaseNotes(repository, baseline, sha, commits, runs),
};
}
async function packageAssets({ build, source, directory }) {
directory = path.resolve(directory);
await fs.mkdir(directory);
const assets = [];
for (const [workflow, artifacts] of Object.entries(build.artifacts)) {
for (const artifact of artifacts) {
const artifactPath = path.resolve(source, artifact.name);
const files = [];
for (const file of await fs.readdir(artifactPath, { recursive: true })) {
if ((await fs.stat(path.join(artifactPath, file))).isFile()) files.push(file);
}
if (!files.length) throw new Error(`Empty artifact: ${artifact.name}`);
if (workflow === 'core') {
const name = `${artifact.name}-${build.version}.zip`;
await exec('chmod', ['-R', '755', artifactPath]);
// Match release.yml: only Magisk has its files at the ZIP root.
const magisk = artifact.name === 'Easytier-Magisk';
await exec('zip', ['-qr', path.join(directory, name), magisk ? '.' : artifact.name], {
cwd: magisk ? artifactPath : path.resolve(source),
});
assets.push(name);
} else {
for (const file of files) {
const name = path.basename(file);
if (assets.includes(name)) throw new Error(`Duplicate release asset: ${name}`);
await fs.copyFile(path.join(artifactPath, file), path.join(directory, name));
assets.push(name);
}
}
}
}
const checksums = [];
for (const name of assets.sort()) {
const data = await fs.readFile(path.join(directory, name));
if (!data.length) throw new Error(`Empty asset: ${name}`);
checksums.push(`${createHash('sha256').update(data).digest('hex')} ${name}\n`);
}
await fs.writeFile(path.join(directory, 'SHA256SUMS'), checksums.join(''));
build.assets = [...assets, 'SHA256SUMS'].sort();
}
async function publish({ github, context, core, build, directory }) {
const repository = context.repo;
const names = build.assets;
const files = (await fs.readdir(directory)).sort();
if (!names?.length || JSON.stringify(files) !== JSON.stringify(names)) {
throw new Error('Packaged files do not match the selected nightly artifacts.');
}
for (const name of files) {
if (!(await fs.stat(path.join(directory, name))).size) throw new Error(`Empty asset: ${name}`);
}
const releases = await github.paginate(github.rest.repos.listReleases, {
...repository, per_page: 100,
});
let release = releases.find(item => item.tag_name === 'nightly');
if (release?.immutable) throw new Error('The nightly release must allow asset and tag updates.');
if ((release?.id || undefined) !== build.release_id) {
throw new Error('The nightly release changed during preparation; run the workflow again.');
}
if (!release) {
const { data } = await github.rest.repos.createRelease({
...repository, tag_name: 'nightly', target_commitish: build.sha,
name: 'Nightly', draft: true, prerelease: true, make_latest: 'false',
});
release = data;
}
const previousAssets = await github.paginate(github.rest.repos.listReleaseAssets, {
...repository, release_id: release.id, per_page: 100,
});
for (const name of files) {
const previous = previousAssets.find(asset => asset.name === name);
if (previous) {
await github.rest.repos.deleteReleaseAsset({ ...repository, asset_id: previous.id });
}
await github.rest.repos.uploadReleaseAsset({
...repository, release_id: release.id, name,
data: await fs.readFile(path.join(directory, name)),
headers: { 'content-type': name.endsWith('.zip') ? 'application/zip' : 'application/octet-stream' },
});
}
// Remove retired platforms only after all replacement assets are uploaded.
for (const asset of previousAssets.filter(asset => !files.includes(asset.name))) {
await github.rest.repos.deleteReleaseAsset({ ...repository, asset_id: asset.id });
}
// A new draft does not necessarily have a tag yet.
try {
await github.rest.git.getRef({ ...repository, ref: 'tags/nightly' });
await github.rest.git.updateRef({ ...repository, ref: 'tags/nightly', sha: build.sha, force: true });
} catch (error) {
if (error.status !== 404) throw error;
await github.rest.git.createRef({ ...repository, ref: 'refs/tags/nightly', sha: build.sha });
}
await github.rest.repos.updateRelease({
...repository, release_id: release.id, name: 'Nightly', body: build.body,
target_commitish: build.sha,
draft: false, prerelease: true, make_latest: 'false',
});
core.info(`Updated nightly at ${build.sha}: ${release.html_url}`);
}
module.exports = { prepare, packageAssets, publish, releaseNotes };
+398
View File
@@ -0,0 +1,398 @@
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { createHash } = require('node:crypto');
const { test } = require('node:test');
const { prepare, packageAssets, publish, releaseNotes } = require('./nightly-release.js');
const repository = { owner: 'EasyTier', repo: 'EasyTier' };
const core = { info() {} };
const notFound = () => Object.assign(new Error('Not found'), { status: 404 });
function fixture() {
const calls = [];
const state = {
release: null,
ancestry: 'ahead',
publishedComparison: 'ahead',
tagExists: false,
version: '2.7.0',
releases: [
{ tag_name: 'v2.6.4', published_at: '2026-05-12', html_url: 'stable' },
{ tag_name: 'v2.7.0', published_at: '2026-10-06', prerelease: true, html_url: 'version' },
{ tag_name: 'v2.8.0', published_at: '2026-10-12', draft: true },
],
runs: Object.fromEntries(['core', 'gui', 'mobile', 'test'].map((name, index) => [name, {
id: index + 1, name, head_sha: 'built', head_branch: 'main',
event: 'workflow_dispatch', conclusion: 'success', html_url: `run/${index + 1}`,
}])),
artifacts: {
1: [{ id: 10, name: 'easytier-linux-x86_64' }, { id: 11, name: 'Easytier-Magisk' },
{ id: 12, name: 'signpath-unsigned-windows-x86_64' }],
2: [{ id: 20, name: 'easytier-gui-windows-x86_64' }],
3: [{ id: 30, name: 'easytier-mobile-android-aarch64' }],
},
assets: [],
};
const repos = {
async getBranch() { return { data: { commit: { sha: 'main-head' } } }; },
async getContent(request) {
calls.push(['version', request.ref]);
assert.equal(request.path, 'easytier-gui/src-tauri/tauri.conf.json');
return { data: { content: Buffer.from(JSON.stringify({ version: state.version })).toString('base64') } };
},
async compareCommitsWithBasehead(request) {
calls.push(['compare', request.basehead]);
return { data: { status: request.basehead.startsWith('nightly...') ? state.publishedComparison : state.ancestry } };
},
listReleases() {},
listReleaseAssets() {},
async createRelease(request) {
calls.push(['create', request]);
state.release = { id: 100, draft: true, html_url: 'nightly' };
return { data: state.release };
},
async deleteReleaseAsset(request) { calls.push(['delete', request.asset_id]); },
async uploadReleaseAsset(request) {
calls.push(['upload', request.name]);
if (state.uploadFails) throw new Error('Upload failed');
},
async updateRelease(request) {
calls.push(['update', request]);
Object.assign(state.release, request);
},
};
const actions = {
async listWorkflowRuns(request) {
assert.equal(request.branch, 'main');
assert.equal(request.event, 'workflow_dispatch');
assert.equal(request.status, 'success');
const run = state.runs[request.workflow_id.replace('.yml', '')];
return { data: { workflow_runs: run ? [run] : [] } };
},
listWorkflowRunArtifacts() {},
};
const github = {
rest: {
repos, actions,
git: {
async getRef() { if (!state.tagExists) throw notFound(); },
async createRef(request) { calls.push(['createTag', request.sha]); state.tagExists = true; },
async updateRef(request) { calls.push(['updateTag', request.sha]); },
},
},
async paginate(method, request, map) {
if (method === repos.listReleases) {
return state.releases.concat(state.release ? [{ tag_name: 'nightly', ...state.release }] : []);
}
if (method === repos.listReleaseAssets) return state.assets;
if (method === actions.listWorkflowRunArtifacts) return state.artifacts[request.run_id];
if (method === repos.compareCommitsWithBasehead) {
calls.push(['changelog', request.basehead]);
assert.equal(request.per_page, 100);
// Exercise the response mapper used to collect every comparison page.
return map({ data: { commits: [commit('old', '2026-10-10T12:00:00Z')] } })
.concat(map({ data: { commits: [commit('new', '2026-10-12T12:00:00Z')] } }));
}
throw new Error('Unexpected API call');
},
};
const context = { repo: repository, payload: { workflow_run: { head_sha: 'built' } } };
return { state, calls, github, context, core };
}
function commit(sha, date) {
return { sha, html_url: `commit/${sha}`, commit: { committer: { date }, message: `${sha} change\n\nDetails` } };
}
test('nightly selects one main commit and uses the last published version, including prereleases', async () => {
const f = fixture();
f.state.release = { id: 99, published_at: '2026-10-13' };
const build = await prepare(f);
assert.equal(build.sha, 'built');
assert.equal(build.version, 'v2.7.0');
assert.ok(f.calls.some(call => call[0] === 'version' && call[1] === 'built'));
assert.deepEqual(build.runs, { core: 1, gui: 2, mobile: 3, test: 4 });
assert.deepEqual(build.artifacts.core.map(a => a.id), [10, 11]);
assert.ok(f.calls.some(call => call[0] === 'changelog' && call[1] === 'v2.7.0...built'));
assert.match(build.body, /Changes since \[v2.7.0\]/);
assert.ok(build.body.indexOf('2026-10-12') < build.body.indexOf('2026-10-10'));
});
test('asset versions come from the built commit rather than the changelog baseline', async () => {
const f = fixture();
f.state.version = '2.8.0-rc.1';
assert.equal((await prepare(f)).version, 'v2.8.0-rc.1');
f.state.version = '../invalid';
await assert.rejects(prepare(f), /valid release version/);
});
test('nightly waits for every component and the full test run', async () => {
for (const workflow of ['core', 'gui', 'mobile', 'test']) {
const f = fixture();
delete f.state.runs[workflow];
assert.equal(await prepare(f), null);
}
});
test('nightly rejects a mismatched commit, branch, event or failed result', async () => {
for (const field of ['head_sha', 'head_branch', 'event', 'conclusion']) {
const f = fixture();
f.state.runs.gui[field] = 'different';
assert.equal(await prepare(f), null);
}
});
test('nightly accepts successful failed-job reruns without requiring a new run ID', async () => {
const f = fixture();
f.state.runs.gui.run_attempt = 2;
assert.equal((await prepare(f)).runs.gui, 2);
});
test('nightly refuses missing or expired artifacts before changing a release', async () => {
for (const artifacts of [[], [{ id: 20, name: 'easytier-gui-windows-x86_64', expired: true }]]) {
const f = fixture();
f.state.artifacts[2] = artifacts;
await assert.rejects(prepare(f), /missing or expired/);
}
});
test('an older completed run cannot replace a newer nightly or a removed main commit', async () => {
const f = fixture();
f.state.ancestry = 'diverged';
assert.equal(await prepare(f), null);
f.state.ancestry = 'ahead';
f.state.release = { id: 100 };
for (const status of ['behind', 'diverged']) {
f.state.publishedComparison = status;
assert.equal(await prepare(f), null);
}
});
test('duplicate completion events skip a fully published commit', async () => {
const f = fixture();
f.state.release = { id: 100, body: 'Commit: [built](url)' };
f.state.publishedComparison = 'identical';
assert.equal(await prepare(f), null);
});
test('nightly can resume an initial draft or an interrupted release-note update', async () => {
const f = fixture();
f.state.release = { id: 100, draft: true };
assert.ok(await prepare(f));
assert.ok(!f.calls.some(call => call[1] === 'nightly...built'));
f.state.release.draft = false;
f.state.release.body = 'Commit: [old](url)';
f.state.publishedComparison = 'identical';
assert.ok(await prepare(f));
});
test('manual publication checks the current main commit', async () => {
const f = fixture();
f.context.payload = {};
for (const run of Object.values(f.state.runs)) run.head_sha = 'main-head';
assert.equal((await prepare(f)).sha, 'main-head');
});
test('scheduled publication uses the completed commit even after main advances', async () => {
const f = fixture();
f.context.payload = {};
const build = await prepare({ ...f, sha: 'built' });
assert.equal(build.sha, 'built');
assert.ok(f.calls.some(call => call[0] === 'compare' && call[1] === 'built...main-head'));
assert.deepEqual(build.runs, { core: 1, gui: 2, mobile: 3, test: 4 });
});
test('nightly requires a published version baseline and a mutable release', async () => {
const f = fixture();
f.state.releases = [];
await assert.rejects(prepare(f), /No published version/);
f.state.release = { immutable: true };
await assert.rejects(prepare(f), /must allow asset and tag updates/);
});
test('notes date every entry in UTC and sort by timestamp, not input order', () => {
const notes = releaseNotes(repository, { tag_name: 'v2.7.0', html_url: 'version' }, 'built', [
commit('middle', '2026-10-12T15:00:00Z'),
commit('oldest', '2026-10-12T01:00:00+08:00'),
commit('newest', '2026-10-12T19:00:00Z'),
], []);
assert.match(notes, /2026-10-11 — oldest change/);
assert.ok(notes.indexOf('newest change') < notes.indexOf('middle change'));
assert.ok(notes.indexOf('middle change') < notes.indexOf('oldest change'));
assert.ok(!notes.includes('Details'));
});
async function packageFiles(t, f) {
const build = await prepare(f);
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'easytier-nightly-test-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const source = path.join(root, 'artifacts');
const directory = path.join(root, 'assets');
const contents = {
'easytier-linux-x86_64': ['easytier-core'],
'Easytier-Magisk': ['module.prop', 'bin/easytier-core'],
'easytier-gui-windows-x86_64': ['easytier-gui_2.7.0_x64-setup.exe'],
'easytier-mobile-android-aarch64': ['app-arm64-release.apk'],
...f.files,
};
for (const artifact of Object.values(build.artifacts).flat()) {
for (const name of contents[artifact.name]) {
const file = path.join(source, artifact.name, name);
await fs.mkdir(path.dirname(file), { recursive: true });
await fs.writeFile(file, `contents of ${name}`, { mode: 0o644 });
}
}
await packageAssets({ build, source, directory });
return { ...f, build, source, directory };
}
test('nightly exposes native installers and matches version-release ZIP names and layout', async t => {
const f = fixture();
f.state.artifacts[1].push({ id: 14, name: 'easytier-web-dashboard' });
f.state.artifacts[2].push(
{ id: 21, name: 'easytier-gui-macos-aarch64' },
{ id: 22, name: 'easytier-gui-linux-x86_64' },
);
f.files = {
'easytier-web-dashboard': ['index.html', 'assets/app.js'],
'easytier-gui-macos-aarch64': ['easytier-gui_2.7.0_aarch64.dmg'],
'easytier-gui-linux-x86_64': [
'easytier-gui_2.7.0_amd64.deb',
'easytier-gui-2.7.0-1.x86_64.rpm',
'easytier-gui_2.7.0_amd64.AppImage',
],
};
const { build, directory } = await packageFiles(t, f);
assert.deepEqual(build.assets, [
'Easytier-Magisk-v2.7.0.zip',
'SHA256SUMS',
'app-arm64-release.apk',
'easytier-gui-2.7.0-1.x86_64.rpm',
'easytier-gui_2.7.0_aarch64.dmg',
'easytier-gui_2.7.0_amd64.AppImage',
'easytier-gui_2.7.0_amd64.deb',
'easytier-gui_2.7.0_x64-setup.exe',
'easytier-linux-x86_64-v2.7.0.zip',
'easytier-web-dashboard-v2.7.0.zip',
]);
for (const [name, entry] of [
['easytier-linux-x86_64', 'easytier-linux-x86_64/easytier-core'],
['easytier-web-dashboard', 'easytier-web-dashboard/assets/app.js'],
['Easytier-Magisk', 'bin/easytier-core'],
]) {
const archive = path.join(directory, `${name}-v2.7.0.zip`);
const files = execFileSync('unzip', ['-Z1', archive], { encoding: 'utf8' }).trim().split('\n');
assert.ok(files.includes(entry));
assert.ok(!files.some(file => file.startsWith('Easytier-Magisk/')));
assert.match(execFileSync('zipinfo', ['-l', archive], { encoding: 'utf8' }), /-rwxr-xr-x/);
}
assert.equal(await fs.readFile(path.join(directory, 'app-arm64-release.apk'), 'utf8'),
'contents of app-arm64-release.apk');
const sums = await fs.readFile(path.join(directory, 'SHA256SUMS'), 'utf8');
assert.equal(sums.trim().split('\n').length, build.assets.length - 1);
for (const line of sums.trim().split('\n')) {
const [checksum, name] = line.split(' ');
const data = await fs.readFile(path.join(directory, name));
assert.equal(checksum, createHash('sha256').update(data).digest('hex'));
}
});
test('packaging refuses missing, empty or duplicate installer artifacts', async t => {
const f = fixture();
const args = await packageFiles(t, f);
const artifactPath = path.join(args.source, 'easytier-mobile-android-aarch64');
await fs.rm(args.directory, { recursive: true });
await fs.rm(artifactPath, { recursive: true });
await assert.rejects(packageAssets(args), /ENOENT/);
await fs.rm(args.directory, { recursive: true });
await fs.mkdir(artifactPath);
await assert.rejects(packageAssets(args), /Empty artifact/);
await fs.rm(args.directory, { recursive: true });
await fs.writeFile(path.join(artifactPath, 'easytier-gui_2.7.0_x64-setup.exe'), 'duplicate');
await assert.rejects(packageAssets(args), /Duplicate release asset/);
});
test('reruns publish only the newest artifact for each name, ignoring expired older attempts', async t => {
const f = fixture();
f.state.artifacts[1].push(
{ id: 13, name: 'easytier-linux-x86_64' },
{ id: 9, name: 'easytier-linux-x86_64', expired: true },
);
const args = await packageFiles(t, f);
assert.deepEqual(args.build.artifacts.core.map(a => a.id), [13, 11]);
await publish(args);
assert.equal(f.calls.filter(call => call[0] === 'upload' && call[1] === 'easytier-linux-x86_64-v2.7.0.zip').length, 1);
});
test('first publication creates one draft, uploads assets and then publishes a prerelease', async t => {
const f = fixture();
await publish(await packageFiles(t, f));
const creation = f.calls.find(call => call[0] === 'create')[1];
assert.equal(creation.draft, true);
assert.equal(creation.prerelease, true);
assert.equal(creation.make_latest, 'false');
const update = f.calls.at(-1);
assert.equal(update[0], 'update');
assert.equal(update[1].release_id, 100);
assert.equal(update[1].draft, false);
assert.equal(update[1].make_latest, 'false');
assert.ok(f.calls.some(call => call[0] === 'createTag'));
});
test('updates keep the release ID and remove obsolete assets only after uploads finish', async t => {
const f = fixture();
f.state.release = { id: 99, draft: false, target_commitish: 'first-build' };
f.state.tagExists = true;
f.state.assets = [
{ id: 50, name: 'easytier-linux-x86_64-v2.7.0.zip' },
{ id: 51, name: 'retired-platform.zip' },
];
await publish(await packageFiles(t, f));
assert.ok(!f.calls.some(call => call[0] === 'create' || call[0] === 'createTag'));
assert.equal(f.calls.at(-1)[1].release_id, 99);
assert.equal(f.state.release.target_commitish, 'built');
const lastUpload = f.calls.findLastIndex(call => call[0] === 'upload');
assert.ok(f.calls.findIndex(call => call[0] === 'delete' && call[1] === 51) > lastUpload);
});
test('incomplete packaging cannot mutate the published release', async t => {
const f = fixture();
const args = await packageFiles(t, f);
await fs.unlink(path.join(args.directory, 'Easytier-Magisk-v2.7.0.zip'));
await assert.rejects(publish(args), /do not match/);
assert.ok(!f.calls.some(call => ['create', 'delete', 'upload', 'update'].includes(call[0])));
});
test('a failed upload does not advance the tag, notes or draft publication', async t => {
const f = fixture();
const args = await packageFiles(t, f);
f.state.uploadFails = true;
await assert.rejects(publish(args), /Upload failed/);
assert.equal(f.state.release.draft, true);
assert.ok(!f.calls.some(call => ['createTag', 'updateTag', 'update'].includes(call[0])));
});
test('a retry resumes the initial draft after an upload failure without creating another release', async t => {
const f = fixture();
const args = await packageFiles(t, f);
f.state.uploadFails = true;
await assert.rejects(publish(args), /Upload failed/);
f.state.uploadFails = false;
const retry = await prepare(f);
assert.equal(retry.release_id, 100);
await fs.rm(args.directory, { recursive: true });
await packageAssets({ ...args, build: retry });
await publish({ ...args, build: retry });
assert.equal(f.calls.filter(call => call[0] === 'create').length, 1);
assert.equal(f.state.release.draft, false);
});
test('publication stops if the release was replaced during artifact downloads', async t => {
const f = fixture();
const args = await packageFiles(t, f);
f.state.release = { id: 999 };
await assert.rejects(publish(args), /changed during preparation/);
});
@@ -0,0 +1,25 @@
module.exports = async ({ github, context, core }) => {
const builds = [
['core.yml', process.env.CORE_RUN_ID],
['gui.yml', process.env.GUI_RUN_ID],
['mobile.yml', process.env.MOBILE_RUN_ID],
];
let commit;
for (const [workflow, runId] of builds) {
const { data: run } = await github.rest.actions.getWorkflowRun({
...context.repo,
run_id: Number(runId),
});
const fullBuild = run.event === 'workflow_dispatch' ||
(run.event === 'push' && run.head_branch.startsWith('releases/'));
if (run.path !== `.github/workflows/${workflow}` || run.conclusion !== 'success' || !fullBuild) {
throw new Error(`${workflow}: run ${runId} must be a successful full build.`);
}
if (commit && run.head_sha !== commit) {
throw new Error('Core, GUI and Mobile must be built from the same commit.');
}
commit = run.head_sha;
core.info(`${workflow}: validated full build ${runId} at ${commit}.`);
}
};
+79 -17
View File
@@ -2,6 +2,11 @@ name: EasyTier Core
on:
workflow_dispatch:
inputs:
sign_windows:
description: Submit Windows artifacts for SignPath test signing
type: boolean
default: true
push:
branches: [ "develop", "main", "releases/**" ]
pull_request:
@@ -9,7 +14,8 @@ on:
types: [ opened, synchronize, reopened, ready_for_review ]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
# A new PR or branch push must not cancel a full build.
group: ${{ github.workflow }}-${{ (github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'releases/')) && 'full' || 'check' }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
@@ -31,7 +37,17 @@ jobs:
"easytier-core/**", "easytier-proto/**", "easytier-rpc-build/**",
"easytier-web/**", "easytier-contrib/easytier-magisk/**",
".github/workflows/core.yml", ".github/actions/**"]
FRONTEND_PATHS: >-
["easytier-web/frontend/**", "easytier-web/frontend-lib/**",
"easytier-web/config-generator/**", "easytier-proto/proto/**",
"easytier-core/src/config/**", "easytier-core/src/lib.rs",
"pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json",
"Cargo.toml", "Cargo.lock", "rust-toolchain.toml", ".cargo/**",
"easytier-core/Cargo.toml", "easytier-proto/Cargo.toml",
".github/workflows/core.yml", ".github/actions/prepare-pnpm/**"]
outputs:
full_build: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'releases/') }}
frontend_changed: ${{ steps.path_filter.outputs.frontend }}
# Release builds must run even if they only change release metadata.
should_skip: >-
${{
@@ -42,15 +58,16 @@ jobs:
steps.path_filter.outputs.relevant != 'true'
)
) &&
github.event_name != 'workflow_dispatch' &&
!startsWith(github.ref_name, 'releases/')
}}
steps:
- id: skip_check
if: github.event_name != 'workflow_dispatch' && !startsWith(github.ref_name, 'releases/')
uses: fkirc/skip-duplicate-actions@v5
with:
concurrent_skipping: 'same_content_newer'
skip_after_successful_duplicate: 'true'
cancel_others: 'true'
paths: ${{ env.RELEVANT_PATHS }}
- id: path_filter
@@ -61,10 +78,54 @@ jobs:
with:
filters: |
relevant: ${{ env.RELEVANT_PATHS }}
frontend: ${{ env.FRONTEND_PATHS }}
check:
name: Check (${{ matrix.TARGET }})
needs: pre_job
if: needs.pre_job.outputs.should_skip != 'true' && needs.pre_job.outputs.full_build != 'true'
strategy:
fail-fast: true
matrix:
include:
- TARGET: x86_64-unknown-linux-musl
OS: ubuntu-24.04
FEATURES: jemalloc
- TARGET: aarch64-apple-darwin
OS: macos-latest
FEATURES: jemalloc
- TARGET: x86_64-pc-windows-msvc
OS: windows-latest
FEATURES: mimalloc
runs-on: ${{ matrix.OS }}
steps:
- uses: actions/checkout@v5
- uses: ./.github/actions/prepare-build
with:
target: ${{ matrix.TARGET }}
gui: false
pnpm: false
token: ${{ github.token }}
- uses: Swatinem/rust-cache@v2
with:
shared-key: core-check-${{ matrix.TARGET }}
save-if: ${{ github.event_name != 'pull_request' }}
- name: Check Core and Web
run: cargo check --locked --all-targets --target ${{ matrix.TARGET }} --features ${{ matrix.FEATURES }}
build_web:
runs-on: ubuntu-latest
needs: pre_job
if: needs.pre_job.outputs.should_skip != 'true'
if: >-
needs.pre_job.outputs.should_skip != 'true' &&
(
needs.pre_job.outputs.full_build == 'true' ||
github.event_name != 'pull_request' ||
needs.pre_job.outputs.frontend_changed == 'true'
)
steps:
- uses: actions/checkout@v5
@@ -75,6 +136,7 @@ jobs:
build-filter: './easytier-web/*'
- name: Archive artifact
if: needs.pre_job.outputs.full_build == 'true'
uses: actions/upload-artifact@v5
with:
name: easytier-web-dashboard
@@ -82,7 +144,7 @@ jobs:
easytier-web/frontend/dist/*
build:
strategy:
fail-fast: true
fail-fast: false
matrix:
include:
- TARGET: x86_64-unknown-linux-musl
@@ -150,7 +212,7 @@ jobs:
needs:
- pre_job
- build_web
if: needs.pre_job.outputs.should_skip != 'true'
if: needs.pre_job.outputs.should_skip != 'true' && needs.pre_job.outputs.full_build == 'true'
steps:
- uses: actions/checkout@v5
@@ -168,8 +230,8 @@ jobs:
uses: ./.github/actions/prepare-build
with:
target: ${{ matrix.TARGET }}
gui: true
pnpm: true
gui: false
pnpm: false
token: ${{ secrets.GITHUB_TOKEN }}
- uses: Swatinem/rust-cache@v2
@@ -177,8 +239,8 @@ jobs:
# The prefix cache key, this can be changed to start a new cache manually.
# default: "v0-rust"
prefix-key: ""
shared-key: "core-registry"
cache-targets: "false"
shared-key: core-release-${{ matrix.TARGET }}
cache-targets: "true"
- uses: mlugg/setup-zig@v2
if: ${{ contains(matrix.OS, 'ubuntu') }}
@@ -286,7 +348,7 @@ jobs:
./artifacts/*
- name: Prepare SignPath test artifact
if: ${{ github.event_name == 'workflow_dispatch' && contains(matrix.TARGET, 'windows') }}
if: ${{ github.event_name == 'workflow_dispatch' && inputs.sign_windows && contains(matrix.TARGET, 'windows') }}
run: |
mkdir -p ./signpath-artifacts
cp \
@@ -297,7 +359,7 @@ jobs:
./signpath-artifacts/
- name: Upload unsigned SignPath test artifact
if: ${{ github.event_name == 'workflow_dispatch' && contains(matrix.TARGET, 'windows') }}
if: ${{ github.event_name == 'workflow_dispatch' && inputs.sign_windows && contains(matrix.TARGET, 'windows') }}
id: signpath-upload
uses: actions/upload-artifact@v5
with:
@@ -306,7 +368,7 @@ jobs:
if-no-files-found: error
- name: Submit SignPath test-signing request
if: ${{ github.event_name == 'workflow_dispatch' && contains(matrix.TARGET, 'windows') }}
if: ${{ github.event_name == 'workflow_dispatch' && inputs.sign_windows && contains(matrix.TARGET, 'windows') }}
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
@@ -319,7 +381,7 @@ jobs:
output-artifact-directory: ./signpath-signed-artifacts
- name: Upload signed SignPath test artifact
if: ${{ github.event_name == 'workflow_dispatch' && contains(matrix.TARGET, 'windows') }}
if: ${{ github.event_name == 'workflow_dispatch' && inputs.sign_windows && contains(matrix.TARGET, 'windows') }}
uses: actions/upload-artifact@v5
with:
name: signpath-signed-${{ matrix.ARTIFACT_NAME }}
@@ -329,7 +391,7 @@ jobs:
build_magisk:
runs-on: ubuntu-latest
needs: [ pre_job, build_web, build ]
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && needs.pre_job.outputs.full_build == 'true' && !cancelled()
steps:
- name: Checkout Code
uses: actions/checkout@v5 # 必须先检出代码才能获取模块配置
@@ -371,9 +433,9 @@ jobs:
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()
needs: [ pre_job, check, build_web, build, build_magisk ]
if: ${{ always() }}
steps:
- name: Mark result as failed
if: contains(needs.*.result, 'failure')
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
run: exit 1
+36 -9
View File
@@ -1,6 +1,7 @@
name: EasyTier GUI
on:
workflow_dispatch:
push:
branches: ["develop", "main", "releases/**"]
pull_request:
@@ -8,7 +9,8 @@ on:
types: [opened, synchronize, reopened, ready_for_review]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
# A new PR or branch push must not cancel a full build.
group: ${{ github.workflow }}-${{ (github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'releases/')) && 'full' || 'check' }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
@@ -32,6 +34,7 @@ jobs:
"easytier-web/frontend-lib/**", ".github/workflows/gui.yml",
".github/actions/**"]
outputs:
full_build: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'releases/') }}
# Release builds must run even if they only change release metadata.
should_skip: >-
${{
@@ -42,15 +45,16 @@ jobs:
steps.path_filter.outputs.relevant != 'true'
)
) &&
github.event_name != 'workflow_dispatch' &&
!startsWith(github.ref_name, 'releases/')
}}
steps:
- id: skip_check
if: github.event_name != 'workflow_dispatch' && !startsWith(github.ref_name, 'releases/')
uses: fkirc/skip-duplicate-actions@v5
with:
concurrent_skipping: 'same_content_newer'
skip_after_successful_duplicate: 'true'
cancel_others: 'true'
paths: ${{ env.RELEVANT_PATHS }}
- id: path_filter
@@ -62,6 +66,29 @@ jobs:
filters: |
relevant: ${{ env.RELEVANT_PATHS }}
check:
name: Check GUI (Linux)
needs: pre_job
if: needs.pre_job.outputs.should_skip != 'true' && needs.pre_job.outputs.full_build != 'true'
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
- uses: ./.github/actions/prepare-build
with:
gui: true
pnpm: true
pnpm-build-filter: easytier-gui...
token: ${{ github.token }}
- uses: Swatinem/rust-cache@v2
with:
shared-key: gui-check-x86_64-unknown-linux-gnu
save-if: ${{ github.event_name != 'pull_request' }}
- name: Check GUI
run: cargo check --locked --all-targets --package easytier-gui --features custom-protocol
build-gui:
strategy:
fail-fast: true
@@ -106,7 +133,7 @@ jobs:
GUI_TARGET: ${{ matrix.GUI_TARGET }}
OSS_BUCKET: ${{ secrets.ALIYUN_OSS_BUCKET }}
needs: pre_job
if: needs.pre_job.outputs.should_skip != 'true'
if: needs.pre_job.outputs.should_skip != 'true' && needs.pre_job.outputs.full_build == 'true'
steps:
- uses: actions/checkout@v5
@@ -120,7 +147,7 @@ jobs:
target: ${{ matrix.TARGET }}
gui: true
pnpm: true
pnpm-build-filter: ''
pnpm-build-filter: easytier-gui...
token: ${{ secrets.GITHUB_TOKEN }}
- uses: Swatinem/rust-cache@v2
@@ -128,8 +155,8 @@ jobs:
# The prefix cache key, this can be changed to start a new cache manually.
# default: "v0-rust"
prefix-key: ""
shared-key: "gui-registry"
cache-targets: "false"
shared-key: gui-release-${{ matrix.GUI_TARGET }}
cache-targets: "true"
- name: copy correct DLLs
if: ${{ contains(matrix.GUI_TARGET, 'windows') }}
@@ -262,9 +289,9 @@ jobs:
gui-result:
runs-on: ubuntu-latest
needs: [ pre_job, build-gui ]
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
needs: [ pre_job, check, build-gui ]
if: ${{ always() }}
steps:
- name: Mark result as failed
if: contains(needs.*.result, 'failure')
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
run: exit 1
+22 -18
View File
@@ -1,6 +1,7 @@
name: EasyTier Mobile
on:
workflow_dispatch:
push:
branches: ["develop", "main", "releases/**"]
pull_request:
@@ -8,7 +9,8 @@ on:
types: [opened, synchronize, reopened, ready_for_review]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
# A new PR or branch push must not cancel a full build.
group: ${{ github.workflow }}-${{ (github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'releases/')) && 'full' || 'check' }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
@@ -32,6 +34,7 @@ jobs:
"easytier-web/frontend-lib/**", ".github/workflows/mobile.yml",
".github/actions/**"]
outputs:
full_build: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'releases/') }}
# Release builds must run even if they only change release metadata.
should_skip: >-
${{
@@ -42,15 +45,16 @@ jobs:
steps.path_filter.outputs.relevant != 'true'
)
) &&
github.event_name != 'workflow_dispatch' &&
!startsWith(github.ref_name, 'releases/')
}}
steps:
- id: skip_check
if: github.event_name != 'workflow_dispatch' && !startsWith(github.ref_name, 'releases/')
uses: fkirc/skip-duplicate-actions@v5
with:
concurrent_skipping: 'same_content_newer'
skip_after_successful_duplicate: 'true'
cancel_others: 'true'
paths: ${{ env.RELEVANT_PATHS }}
- id: path_filter
@@ -66,19 +70,11 @@ jobs:
strategy:
fail-fast: true
matrix:
include:
- TARGET: aarch64-linux-android
ARCH: aarch64
- TARGET: armv7-linux-androideabi
ARCH: armv7
- TARGET: i686-linux-android
ARCH: i686
- TARGET: x86_64-linux-android
ARCH: x86_64
ARCH: ${{ fromJSON(needs.pre_job.outputs.full_build == 'true' && '["aarch64", "armv7", "i686", "x86_64"]' || '["aarch64"]') }}
runs-on: ubuntu-latest
env:
NAME: easytier
TARGET: ${{ matrix.TARGET }}
TARGET: ${{ matrix.ARCH == 'armv7' && 'armv7-linux-androideabi' || format('{0}-linux-android', matrix.ARCH) }}
ARCH: ${{ matrix.ARCH }}
OSS_BUCKET: ${{ secrets.ALIYUN_OSS_BUCKET }}
needs: pre_job
@@ -110,10 +106,10 @@ jobs:
- name: Prepare build environment
uses: ./.github/actions/prepare-build
with:
target: ${{ matrix.TARGET }}
target: ${{ env.TARGET }}
gui: false
pnpm: true
pnpm-build-filter: ''
pnpm-build-filter: easytier-gui...
token: ${{ secrets.GITHUB_TOKEN }}
- uses: Swatinem/rust-cache@v2
@@ -121,15 +117,22 @@ jobs:
# The prefix cache key, this can be changed to start a new cache manually.
# default: "v0-rust"
prefix-key: ""
shared-key: "gui-registry"
cache-targets: "false"
shared-key: mobile-${{ env.TARGET }}-${{ needs.pre_job.outputs.full_build }}
cache-targets: "true"
save-if: ${{ github.event_name != 'pull_request' }}
- name: Check Android ARM64
if: needs.pre_job.outputs.full_build != 'true'
run: cargo check --locked --lib --target "$TARGET" --package easytier-gui --features custom-protocol
- name: Build
if: needs.pre_job.outputs.full_build == 'true'
run: |
cd easytier-gui
pnpm tauri android build --apk --target "$ARCH" --split-per-abi
- name: Collect artifact
if: needs.pre_job.outputs.full_build == 'true'
run: |
mkdir -p ./artifacts/objects/
mv easytier-gui/src-tauri/gen/android/app/build/outputs/apk/*/release/*.apk ./artifacts/objects/
@@ -144,6 +147,7 @@ jobs:
rm -rf ./artifacts/objects/
- name: Archive artifact
if: needs.pre_job.outputs.full_build == 'true'
uses: actions/upload-artifact@v5
with:
name: easytier-mobile-android-${{ matrix.ARCH }}
@@ -153,8 +157,8 @@ jobs:
mobile-result:
runs-on: ubuntu-latest
needs: [ pre_job, build-mobile ]
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled()
if: ${{ always() }}
steps:
- name: Mark result as failed
if: contains(needs.*.result, 'failure')
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
run: exit 1
+104
View File
@@ -0,0 +1,104 @@
name: EasyTier Nightly Release
on:
workflow_call:
inputs:
sha:
description: Commit whose full builds the caller has waited for
required: true
type: string
workflow_run:
workflows: [EasyTier Core, EasyTier GUI, EasyTier Mobile, EasyTier Test]
branches: [main]
types: [completed]
workflow_dispatch:
concurrency:
group: easytier-nightly-release
cancel-in-progress: false
permissions:
actions: read
contents: write
jobs:
release:
if: >-
((github.event_name == 'workflow_dispatch' || github.event_name == 'schedule') &&
github.ref == 'refs/heads/main') ||
(github.event.workflow_run.event == 'workflow_dispatch' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_repository.full_name == github.repository)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
ref: main
- name: Select complete main builds and prepare release notes
id: prepare
uses: actions/github-script@v8
env:
BUILD_SHA: ${{ inputs.sha }}
with:
script: |
const fs = require('node:fs');
const { prepare } = require('./.github/scripts/nightly-release.js');
const build = await prepare({ github, context, core, sha: process.env.BUILD_SHA });
if (!build) return;
fs.writeFileSync('nightly-release.json', JSON.stringify(build));
for (const workflow of ['core', 'gui', 'mobile']) {
core.setOutput(`${workflow}_run_id`, build.runs[workflow]);
core.setOutput(`${workflow}_artifact_ids`, build.artifacts[workflow].map(a => a.id).join(','));
// download-artifact extracts a single artifact directly into path.
const suffix = build.artifacts[workflow].length === 1 ? `/${build.artifacts[workflow][0].name}` : '';
core.setOutput(`${workflow}_path`, `nightly-artifacts${suffix}`);
}
core.setOutput('ready', 'true');
- name: Download Core artifacts
if: steps.prepare.outputs.ready == 'true'
uses: actions/download-artifact@v5
with:
github-token: ${{ github.token }}
run-id: ${{ steps.prepare.outputs.core_run_id }}
artifact-ids: ${{ steps.prepare.outputs.core_artifact_ids }}
path: ${{ steps.prepare.outputs.core_path }}
- name: Download GUI artifacts
if: steps.prepare.outputs.ready == 'true'
uses: actions/download-artifact@v5
with:
github-token: ${{ github.token }}
run-id: ${{ steps.prepare.outputs.gui_run_id }}
artifact-ids: ${{ steps.prepare.outputs.gui_artifact_ids }}
path: ${{ steps.prepare.outputs.gui_path }}
- name: Download Mobile artifacts
if: steps.prepare.outputs.ready == 'true'
uses: actions/download-artifact@v5
with:
github-token: ${{ github.token }}
run-id: ${{ steps.prepare.outputs.mobile_run_id }}
artifact-ids: ${{ steps.prepare.outputs.mobile_artifact_ids }}
path: ${{ steps.prepare.outputs.mobile_path }}
- name: Package assets like version releases
if: steps.prepare.outputs.ready == 'true'
uses: actions/github-script@v8
with:
script: |
const fs = require('node:fs');
const { packageAssets } = require('./.github/scripts/nightly-release.js');
const build = require(`${process.env.GITHUB_WORKSPACE}/nightly-release.json`);
await packageAssets({ build, source: 'nightly-artifacts', directory: 'nightly-assets' });
fs.writeFileSync('nightly-release.json', JSON.stringify(build));
- name: Update the fixed nightly prerelease
if: steps.prepare.outputs.ready == 'true'
uses: actions/github-script@v8
with:
script: |
const { publish } = require('./.github/scripts/nightly-release.js');
const build = require(`${process.env.GITHUB_WORKSPACE}/nightly-release.json`);
await publish({ github, context, core, build, directory: 'nightly-assets' });
+28
View File
@@ -0,0 +1,28 @@
name: EasyTier Nightly Retry
on:
workflow_run:
workflows: [EasyTier Core, EasyTier GUI, EasyTier Mobile, EasyTier Test]
branches: [main]
types: [completed]
permissions:
actions: write
jobs:
retry:
if: >-
github.event.workflow_run.event == 'workflow_dispatch' &&
github.event.workflow_run.conclusion == 'failure' &&
github.event.workflow_run.run_attempt == 1 &&
github.event.workflow_run.head_repository.full_name == github.repository
runs-on: ubuntu-latest
steps:
- name: Retry failed jobs once
uses: actions/github-script@v8
with:
script: |
await github.rest.actions.reRunWorkflowFailedJobs({
...context.repo,
run_id: context.payload.workflow_run.id,
});
+42
View File
@@ -0,0 +1,42 @@
name: EasyTier Nightly
on:
schedule:
# 03:17 Asia/Shanghai; avoid the busy start of the hour.
- cron: '17 19 * * *'
workflow_dispatch:
concurrency:
group: easytier-nightly
cancel-in-progress: false
permissions:
actions: write
contents: read
jobs:
dispatch:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 180
outputs:
sha: ${{ steps.build.outputs.sha }}
steps:
- uses: actions/checkout@v5
- name: Build main and wait for successful results
id: build
uses: actions/github-script@v8
with:
script: |
const dispatch = require('./.github/scripts/nightly-build.js');
core.setOutput('sha', await dispatch({ github, context, core }));
release:
needs: dispatch
permissions:
actions: read
contents: write
uses: ./.github/workflows/nightly-release.yml
with:
sha: ${{ needs.dispatch.outputs.sha }}
+40 -16
View File
@@ -80,32 +80,66 @@ jobs:
uses: ErBWs/setup-ohos@v1
- name: Install ohrs
if: github.event_name != 'pull_request'
uses: taiki-e/install-action@v2
with:
tool: ohrs
- name: Build HAR
id: package
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
- name: Prepare OHOS tools
run: |
set -euo pipefail
sudo apt-get install -qqy \
pkg-config curl libgl1-mesa-dev expect llvm clang lldb lld
rustup component add rustfmt
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: Check formatting
run: |
cargo fmt --all --manifest-path \
easytier-contrib/easytier-ohrs/Cargo.toml -- --check
- name: Test OHOS bridge
run: |
cargo test --locked --manifest-path \
easytier-contrib/easytier-ohrs/Cargo.toml \
-p easytier-ohos-core -p easytier-ohos-features \
--lib -- --test-threads=1
- name: Check OHOS bindings
run: |
cargo check --locked --manifest-path \
easytier-contrib/easytier-ohrs/Cargo.toml \
-p easytier-ohrs --tests
- name: Check OHOS target
if: github.event_name == 'pull_request'
working-directory: easytier-contrib/easytier-ohrs
run: |
source env.sh
cargo check --locked -p easytier-ohrs --tests \
--target aarch64-unknown-linux-ohos
- name: Build HAR
if: github.event_name != 'pull_request'
id: package
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
set -euo pipefail
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)
# Rolling tags such as nightly are not package versions.
last_tag=$(git describe --tags --match 'v[0-9]*' --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)
@@ -149,17 +183,6 @@ jobs:
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
@@ -167,6 +190,7 @@ jobs:
mv package.har "$package_name.har"
- name: Upload HAR
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@v5
with:
name: ${{ steps.package.outputs.name }}
+12
View File
@@ -27,6 +27,7 @@ on:
required: true
permissions:
actions: read
contents: write
jobs:
@@ -37,6 +38,17 @@ jobs:
name: Checkout
uses: actions/checkout@v5
- name: Validate complete builds
uses: actions/github-script@v8
env:
CORE_RUN_ID: ${{ inputs.core_run_id }}
GUI_RUN_ID: ${{ inputs.gui_run_id }}
MOBILE_RUN_ID: ${{ inputs.mobile_run_id }}
with:
script: |
const validate = require('./.github/scripts/validate-release-builds.js');
await validate({ github, context, core });
- name: Download Core Artifact
uses: dawidd6/action-download-artifact@v11
with:
+47 -28
View File
@@ -1,13 +1,16 @@
name: EasyTier Test
on:
workflow_dispatch:
push:
branches: [ "develop", "main" ]
branches: [ "develop", "main", "releases/**" ]
pull_request:
branches: [ "develop", "main" ]
types: [ opened, synchronize, reopened, ready_for_review ]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
# A new PR or branch push must not cancel a full build.
group: ${{ github.workflow }}-${{ (github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'releases/')) && 'full' || 'check' }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
@@ -21,27 +24,48 @@ defaults:
shell: bash
jobs:
ci-scripts:
name: Test CI scripts
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 22
- run: node --test .github/scripts/*.test.js
pre_job:
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
env:
RELEVANT_PATHS: >-
["Cargo.toml", "Cargo.lock", "rust-toolchain.toml", ".cargo/**",
".config/nextest.toml",
"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:
full_build: ${{ github.event_name == 'workflow_dispatch' || 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'
)
steps.skip_check.outputs.should_skip == 'true' ||
(
github.event_name == 'pull_request' &&
steps.path_filter.outputs.relevant != 'true'
)
) &&
github.event_name != 'workflow_dispatch' &&
!startsWith(github.ref_name, 'releases/')
}}
steps:
- id: skip_check
if: github.event_name != 'workflow_dispatch' && !startsWith(github.ref_name, 'releases/')
uses: fkirc/skip-duplicate-actions@v5
with:
concurrent_skipping: 'never'
@@ -69,16 +93,23 @@ jobs:
uses: ./.github/actions/prepare-build
with:
gui: true
pnpm: true
pnpm: ${{ needs.pre_job.outputs.full_build == 'true' }}
token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
components: rustfmt,clippy
target: wasm32-wasip1
cache: false
rustflags: ''
- uses: Swatinem/rust-cache@v2
with:
shared-key: test-check
save-if: ${{ github.event_name != 'pull_request' }}
- uses: taiki-e/install-action@cargo-hack
if: needs.pre_job.outputs.full_build == 'true'
- name: Check formatting
if: ${{ !cancelled() }}
@@ -89,7 +120,7 @@ jobs:
run: cargo clippy --all-targets --features full --all -- -D warnings
- name: Check features
if: ${{ !cancelled() }}
if: ${{ !cancelled() && needs.pre_job.outputs.full_build == 'true' }}
run: cargo hack check --package easytier --each-feature --exclude-features macos-ne --verbose
- name: Check WASI
@@ -117,8 +148,8 @@ jobs:
- name: Prepare build environment
uses: ./.github/actions/prepare-build
with:
gui: true
pnpm: true
gui: false
pnpm: false
token: ${{ secrets.GITHUB_TOKEN }}
- uses: Swatinem/rust-cache@v2
@@ -136,23 +167,11 @@ jobs:
path: tests.tar.zst
retention-days: 1
test_matrix:
name: Test (${{ matrix.name }})
run-tests:
name: Run tests
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
@@ -187,13 +206,13 @@ jobs:
run: |
sudo prlimit --pid $$ --nofile=1048576:1048576
sudo -E env "PATH=$PATH" EASYTIER_LINUX_BPF_INTEGRATION=required \
cargo nextest run --archive-file tests.tar.zst ${{ matrix.opts }}
cargo nextest run --archive-file tests.tar.zst --profile ci --no-fail-fast
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()
needs: [ pre_job, ci-scripts, check, pre-test, run-tests ]
if: ${{ always() }}
steps:
- name: Mark result as failed
if: contains(needs.*.result, 'failure')
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
run: exit 1
+8
View File
@@ -91,6 +91,14 @@ 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.
## Embedded logging
Logging belongs to the WASI runtime, which installs its subscriber at module
startup. The independent logger ABI and process-level `LoggerRpc` control
the same module-wide level, including before any Instance exists. Instance
configuration and lifecycle never initialize or reset logging. The Host owns
the output sink through WASI stdout; output errors do not interrupt Core.
## Web compatibility Host
The Web compatibility Host runs the portable EasyTier guest in JavaScript
Generated
+87 -114
View File
@@ -75,25 +75,11 @@ dependencies = [
"aead 0.5.2",
"aes 0.8.4",
"cipher 0.4.4",
"ctr 0.9.2",
"ghash 0.5.1",
"ctr",
"ghash",
"subtle",
]
[[package]]
name = "aes-gcm"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f"
dependencies = [
"aead 0.6.1",
"aes 0.9.3",
"cipher 0.5.2",
"ctr 0.10.1",
"ctutils",
"ghash 0.6.0",
]
[[package]]
name = "ahash"
version = "0.8.12"
@@ -1957,15 +1943,6 @@ dependencies = [
"cipher 0.4.4",
]
[[package]]
name = "ctr"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21"
dependencies = [
"cipher 0.5.2",
]
[[package]]
name = "ctutils"
version = "0.4.2"
@@ -2537,7 +2514,7 @@ dependencies = [
"atomic-shim",
"atomic-write-file",
"atomic_refcell",
"base64 0.23.1",
"base64 0.22.1",
"bon",
"boringtun-easytier",
"bytecodec",
@@ -2570,7 +2547,7 @@ dependencies = [
"hickory-proto",
"hickory-resolver",
"hickory-server",
"hkdf 0.13.0",
"hkdf",
"http",
"http-body-util",
"humansize",
@@ -2595,6 +2572,7 @@ dependencies = [
"pin-project-lite",
"pnet_datalink",
"prost 0.14.4",
"prost-types 0.14.4",
"quanta",
"quinn",
"quinn-proto",
@@ -2609,7 +2587,7 @@ dependencies = [
"serde_json",
"serial_test",
"service-manager",
"sha2 0.11.0",
"sha2 0.10.9",
"shellexpand",
"smoltcp",
"socket2",
@@ -2663,7 +2641,7 @@ dependencies = [
name = "easytier-core"
version = "2.7.0"
dependencies = [
"aes-gcm 0.11.1",
"aes-gcm",
"anyhow",
"arc-swap",
"ariadne",
@@ -2671,27 +2649,28 @@ dependencies = [
"async-trait",
"atomic-shim",
"auto_impl",
"base64 0.23.1",
"base64 0.22.1",
"bitflags 2.13.2",
"bon",
"bytecodec",
"bytes",
"chacha20poly1305 0.11.0",
"chacha20poly1305 0.10.1",
"chrono",
"cidr",
"crossbeam",
"crossbeam-utils",
"dashmap",
"easytier-proto",
"futures",
"getrandom 0.2.17",
"getrandom 0.3.4",
"guarden",
"hmac 0.13.0",
"hmac 0.12.1",
"http-body-util",
"hyper",
"hyper-util",
"idna",
"openssl",
"optionize",
"ordered_hash_map",
"parking_lot",
"percent-encoding",
@@ -2699,14 +2678,16 @@ dependencies = [
"pin-project-lite",
"prefix-trie 0.10.1",
"prost 0.14.4",
"prost-reflect 0.16.5",
"prost-types 0.14.4",
"quanta",
"rand 0.8.8",
"ring",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"sha2 0.11.0",
"sha2 0.10.9",
"smoltcp",
"snow",
"strum 0.28.0",
@@ -2811,12 +2792,12 @@ dependencies = [
name = "easytier-ohos-features"
version = "0.1.0"
dependencies = [
"base64 0.23.1",
"base64 0.22.1",
"easytier",
"flate2",
"gethostname",
"once_cell",
"prost-reflect",
"prost-reflect 0.14.7",
"rusqlite",
"serde",
"serde_json",
@@ -2858,24 +2839,27 @@ dependencies = [
"anyhow",
"async-trait",
"auto_impl",
"base64 0.23.1",
"base64 0.22.1",
"bytes",
"chrono",
"cidr",
"hmac 0.13.0",
"hmac 0.12.1",
"indoc",
"optionize",
"pbjson",
"pbjson-build",
"proc-macro2",
"prost 0.14.4",
"prost-build",
"prost-reflect 0.16.5",
"prost-types 0.14.4",
"prost-wkt-types",
"quote",
"reqwest 0.13.5",
"reqwest",
"serde",
"serde_json",
"sha2 0.11.0",
"sha2 0.10.9",
"strum 0.28.0",
"thiserror 2.0.20",
"tokio",
"url",
@@ -2924,7 +2908,7 @@ dependencies = [
"axum-embed",
"axum-login",
"axum-messages",
"base64 0.23.1",
"base64 0.22.1",
"chrono",
"cidr",
"clap",
@@ -2937,18 +2921,20 @@ dependencies = [
"imageproc",
"maxminddb",
"mimalloc",
"oauth2-reqwest",
"openidconnect",
"optionize",
"password-auth",
"prost 0.14.4",
"rand 0.8.8",
"reqwest 0.13.5",
"reqwest",
"rust-embed",
"rust-i18n",
"sea-orm",
"sea-orm-migration",
"serde",
"serde_json",
"sha2 0.11.0",
"sha2 0.10.9",
"sqlx",
"subtle",
"sys-locale",
@@ -2956,6 +2942,7 @@ dependencies = [
"thunk-rs",
"tokio",
"tokio-util",
"toml 1.1.6+spec-1.1.0",
"tower",
"tower-http 0.7.1",
"tower-sessions",
@@ -3025,7 +3012,7 @@ dependencies = [
"ff",
"generic-array",
"group",
"hkdf 0.12.4",
"hkdf",
"pem-rfc7468",
"pkcs8",
"rand_core 0.6.4",
@@ -3778,16 +3765,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval 0.6.2",
]
[[package]]
name = "ghash"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5"
dependencies = [
"polyval 0.7.3",
"polyval",
]
[[package]]
@@ -4297,15 +4275,6 @@ dependencies = [
"hmac 0.12.1",
]
[[package]]
name = "hkdf"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018"
dependencies = [
"hmac 0.13.0",
]
[[package]]
name = "hmac"
version = "0.12.1"
@@ -6070,7 +6039,6 @@ dependencies = [
"getrandom 0.2.17",
"http",
"rand 0.8.8",
"reqwest 0.12.28",
"serde",
"serde_json",
"serde_path_to_error",
@@ -6079,6 +6047,16 @@ dependencies = [
"url",
]
[[package]]
name = "oauth2-reqwest"
version = "0.1.0-alpha.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234fb5c965bbce983ee5de636a7a51d6a3223da8067ea02f9ab2d2d78ac08be2"
dependencies = [
"oauth2",
"reqwest",
]
[[package]]
name = "objc2"
version = "0.6.4"
@@ -6414,6 +6392,30 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "optionize"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b6a0111dde86433acde952fdb2757eb65a79ae0e3e58acf378544280d9c3104"
dependencies = [
"derive_more",
"optionize-macros",
]
[[package]]
name = "optionize-macros"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92f30b98a08383cf48fd3d5cc3e3dd27b4a537d94e8488aadff7f3625288b360"
dependencies = [
"darling 0.24.1",
"derive_more",
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "ordered-float"
version = "2.10.1"
@@ -7001,17 +7003,6 @@ dependencies = [
"universal-hash 0.5.1",
]
[[package]]
name = "polyval"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd"
dependencies = [
"cpubits",
"cpufeatures 0.3.1",
"universal-hash 0.6.1",
]
[[package]]
name = "portable-atomic"
version = "1.15.0"
@@ -7289,6 +7280,16 @@ dependencies = [
"prost-types 0.13.5",
]
[[package]]
name = "prost-reflect"
version = "0.16.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01b80ea363c31af2de2b92e3c07ed1156628f7838c4afb4df75ee78a37fedbd1"
dependencies = [
"prost 0.14.4",
"prost-types 0.14.4",
]
[[package]]
name = "prost-reflect-derive"
version = "0.14.0"
@@ -7743,38 +7744,6 @@ version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower",
"tower-http 0.6.11",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "reqwest"
version = "0.13.5"
@@ -8788,6 +8757,12 @@ dependencies = [
"digest 0.11.3",
]
[[package]]
name = "sha1_smol"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
[[package]]
name = "sha2"
version = "0.10.9"
@@ -8979,12 +8954,9 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "599b506ccc4aff8cf7844bc42cf783009a434c1e26c964432560fb6d6ad02d82"
dependencies = [
"aes-gcm 0.10.3",
"blake2 0.10.6",
"chacha20poly1305 0.10.1",
"curve25519-dalek 4.1.3",
"getrandom 0.3.4",
"ring",
"rustc_version",
"sha2 0.10.9",
"subtle",
@@ -9182,7 +9154,7 @@ dependencies = [
"futures-util",
"generic-array",
"hex",
"hkdf 0.12.4",
"hkdf",
"hmac 0.12.1",
"itoa",
"log",
@@ -9225,7 +9197,7 @@ dependencies = [
"futures-core",
"futures-util",
"hex",
"hkdf 0.12.4",
"hkdf",
"hmac 0.12.1",
"home",
"itoa",
@@ -9633,7 +9605,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest 0.13.5",
"reqwest",
"serde",
"serde_json",
"serde_repr",
@@ -10675,7 +10647,7 @@ checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
[[package]]
name = "tun-easytier"
version = "1.1.1"
source = "git+https://github.com/EasyTier/rust-tun#12378839e7985283df0e4fb536b7137230356db5"
source = "git+https://github.com/EasyTier/rust-tun?rev=5ba6be073e5b486b68c12f80fac5cfb50601f024#5ba6be073e5b486b68c12f80fac5cfb50601f024"
dependencies = [
"bytes",
"cfg-if",
@@ -10916,6 +10888,7 @@ dependencies = [
"js-sys",
"rand 0.10.2",
"serde_core",
"sha1_smol",
"wasm-bindgen",
]
+10 -4
View File
@@ -22,6 +22,7 @@ edition = "2024"
rust-version = "1.95"
[workspace.dependencies]
optionize = "0.5"
# Shared versions and sources; members select features and optional dependencies.
anyhow = "1.0"
arc-swap = "1.9"
@@ -29,7 +30,8 @@ async-trait = "0.1.92"
atomic-shim = "0.2.0"
auto_impl = "1.3.0"
axum = "0.8"
base64 = "0.23"
# Match pbjson 0.9's base64 dependency; upgrade together to avoid two codecs.
base64 = "0.22.1"
bon = "3.10.1"
bytecodec = "0.5.0"
bytes = "1.12.1"
@@ -37,6 +39,7 @@ chrono = "0.4.45"
cidr = "0.3.2"
clap = "4.6.7"
crossbeam = "0.8.5"
crossbeam-utils = "0.8.23"
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 }
@@ -44,10 +47,12 @@ easytier-ffi = { version = "0.1.0", path = "easytier-contrib/easytier-ffi", defa
easytier-ohos-core = { version = "0.1.0", path = "easytier-contrib/easytier-ohrs/crates/easytier-ohos-core", default-features = false }
easytier-ohos-features = { version = "0.1.0", path = "easytier-contrib/easytier-ohrs/crates/easytier-ohos-features", default-features = false }
easytier-proto = { version = "2.7.0", path = "easytier-proto", default-features = false }
futures = "0.3"
futures = { version = "0.3", default-features = false }
gethostname = "1.1"
guarden = "0.3"
hmac = "0.13.0"
# Keep HMAC/SHA2 on digest 0.10, shared by stun_codec 0.4 and snow 0.10.
# Upgrade this crypto family together when those upstream constraints move.
hmac = "0.12.1"
http-body-util = "0.1"
hyper = { version = "1", default-features = false }
hyper-util = { version = "0.1", default-features = false }
@@ -71,7 +76,8 @@ sea-orm = "1.1.20"
sea-orm-migration = "1.1.20"
serde = "1.0.229"
serde_json = "1.0"
sha2 = "0.11.0"
# See the HMAC constraint above; SHA2 must use the same digest generation.
sha2 = "0.10.9"
smoltcp = { version = "0.14.0", default-features = false }
sqlx = "0.8.6"
strum = "0.28.0"
+11 -2
View File
@@ -7,7 +7,7 @@
[![GitHub Core Actions](https://github.com/EasyTier/EasyTier/actions/workflows/core.yml/badge.svg)](https://github.com/EasyTier/EasyTier/actions/workflows/core.yml)
[![GitHub GUI Actions](https://github.com/EasyTier/EasyTier/actions/workflows/gui.yml/badge.svg)](https://github.com/EasyTier/EasyTier/actions/workflows/gui.yml)
[![GitHub Test Actions](https://github.com/EasyTier/EasyTier/actions/workflows/test.yml/badge.svg)](https://github.com/EasyTier/EasyTier/actions/workflows/test.yml)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/EasyTier/EasyTier)
[<img src="https://devin.ai/assets/askdeepwiki.png" alt="Ask DeepWiki" height="20"/>](https://deepwiki.com/EasyTier/EasyTier)
[简体中文](/README_CN.md) | [English](/README.md)
@@ -18,7 +18,7 @@
<img src="assets/running-page.png" width="300" alt="running page">
</p>
📚 **[Full Documentation](https://easytier.cn/en/)** | 🖥️ **[Web Console](https://easytier.cn/web)** | 📝 **[Download Releases](https://github.com/EasyTier/EasyTier/releases)** | 🧩 **[Third Party Tools](https://easytier.cn/en/guide/installation_gui.html#third-party-graphical-interfaces)** | ❤️ **[Sponsor](#sponsor)**
📚 **[Full Documentation](https://easytier.cn/en/)** | 🖥️ **[Web Console](https://easytier.cn/web)** | 📝 **[Download Releases](https://github.com/EasyTier/EasyTier/releases)** | **[Nightly](https://github.com/EasyTier/EasyTier/releases/tag/nightly)** | 🧩 **[Third Party Tools](https://easytier.cn/en/guide/installation_gui.html#third-party-graphical-interfaces)** | ❤️ **[Sponsor](#sponsor)**
## Features
@@ -69,8 +69,17 @@ Install via cargo (Latest development version):
cargo install --git https://github.com/EasyTier/EasyTier.git easytier
```
Install prebuilt CLI tools with [cargo-binstall](https://github.com/cargo-bins/cargo-binstall) (specify an existing release version):
```bash
cargo binstall --git https://github.com/EasyTier/EasyTier.git --version 2.6.4 easytier
```
This installs `easytier-core` and `easytier-cli` using the repository's metadata, independently of crates.io publication. On Windows, also copy the DLL and driver files from the matching release ZIP beside the installed binaries; binstall only installs executables.
[Install pre-built binary](https://github.com/EasyTier/EasyTier/releases) (Recommended, All platforms supported)
For a smaller executable, try [easytier-mini](https://github.com/EasyTier/easytier-mini), which supports basic TCP/UDP networking. See its documentation for supported features and usage.
[Install via Docker](https://easytier.cn/en/guide/installation.html#installation-methods)
[Install OpenWrt ipk package](https://github.com/EasyTier/luci-app-easytier)
+11 -2
View File
@@ -7,7 +7,7 @@
[![GitHub Core Actions](https://github.com/EasyTier/EasyTier/actions/workflows/core.yml/badge.svg)](https://github.com/EasyTier/EasyTier/actions/workflows/core.yml)
[![GitHub GUI Actions](https://github.com/EasyTier/EasyTier/actions/workflows/gui.yml/badge.svg)](https://github.com/EasyTier/EasyTier/actions/workflows/gui.yml)
[![GitHub Test Actions](https://github.com/EasyTier/EasyTier/actions/workflows/test.yml/badge.svg)](https://github.com/EasyTier/EasyTier/actions/workflows/test.yml)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/EasyTier/EasyTier)
[<img src="https://devin.ai/assets/askdeepwiki.png" alt="Ask DeepWiki" height="20"/>](https://deepwiki.com/EasyTier/EasyTier)
[简体中文](/README_CN.md) | [English](/README.md)
@@ -18,7 +18,7 @@
<img src="assets/running-page.png" width="300" alt="运行页面">
</p>
📚 **[完整文档](https://easytier.cn)** | 🖥️ **[Web 控制台](https://easytier.cn/web)** | 📝 **[下载发布版本](https://github.com/EasyTier/EasyTier/releases)** | 🧩 **[第三方工具](https://easytier.cn/guide/installation_gui.html#%E7%AC%AC%E4%B8%89%E6%96%B9%E5%9B%BE%E5%BD%A2%E7%95%8C%E9%9D%A2)** | ❤️ **[赞助](#赞助)**
📚 **[完整文档](https://easytier.cn)** | 🖥️ **[Web 控制台](https://easytier.cn/web)** | 📝 **[下载发布版本](https://github.com/EasyTier/EasyTier/releases)** | **[Nightly](https://github.com/EasyTier/EasyTier/releases/tag/nightly)** | 🧩 **[第三方工具](https://easytier.cn/guide/installation_gui.html#%E7%AC%AC%E4%B8%89%E6%96%B9%E5%9B%BE%E5%BD%A2%E7%95%8C%E9%9D%A2)** | ❤️ **[赞助](#赞助)**
## 特性
@@ -69,8 +69,17 @@ irm "https://github.com/EasyTier/EasyTier/blob/main/script/install.ps1?raw=true"
cargo install --git https://github.com/EasyTier/EasyTier.git easytier
```
通过 [cargo-binstall](https://github.com/cargo-bins/cargo-binstall) 安装预编译命令行工具(请指定已有 release 的版本):
```bash
cargo binstall --git https://github.com/EasyTier/EasyTier.git --version 2.6.4 easytier
```
此方式使用仓库中的 metadata 安装 `easytier-core` 和 `easytier-cli`,不依赖 crates.io 发布。在 Windows 上,还需将对应 release ZIP 中的 DLL 和驱动文件复制到安装后的程序旁;binstall 只安装可执行文件。
[下载预编译文件](https://github.com/EasyTier/EasyTier/releases)(推荐,支持所有平台)
需要更小的可执行文件,可使用 [easytier-mini](https://github.com/EasyTier/easytier-mini):支持基础 TCP/UDP 组网,功能范围与使用方式见其文档。
[通过 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)
+125
View File
@@ -179,6 +179,21 @@ wire codecs. It does not own socket I/O or connection policy.
- UDP session multiplexing, classification, and lifecycle;
- in-process Ring sockets.
UDP sessions expose a scoped hole-punch source observer keyed by transaction
ID. Listeners carry a registration handle for their existing receive loop, which
reports the first matching source; dropping the observer removes the registration.
Cone clients optionally prepare that transaction in the listener-selection RPC;
the response echoes its ID only after registration. The registration retains
early UDP sources until the cone-sending RPC claims it. Unclaimed registrations
expire after 30 seconds through the existing listener cleanup loop, covering
local socket and STUN preparation without a separate receive or sending task.
Clients omitting the optional transaction retain registration at the sending RPC;
older servers omit the echoed ID and do not confirm early registration.
Connectivity owns preparation expiry, the active RPC lifetime, candidate selection
and bounded sending to the signaled and observed addresses.
Cone clients start the existing UDP handshake without requiring a reverse
hole-punch packet, and the session layer remains the sole receiver.
`host/` is the single home of Host capability seams:
- DNS and DNS record resolution;
@@ -349,6 +364,14 @@ A core instance:
`CoreProcessRuntime`, plus optional protocol and platform capabilities. The
bundle carries capabilities, not preconstructed portable managers.
The standard native factory owns one continuously driven Tokio executor per
instance. `multi_thread` selects a multi-thread or current-thread runtime;
`multi_thread_count` sets the worker count with the historical minimum of two.
Construction, start/stop, and FFI data-plane tasks use that executor, independent
of the process management runtime. Dropping its Host owner shuts down the
executor without blocking a worker on its own termination. Compact and WASI
Hosts retain their externally supplied execution model.
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
@@ -390,6 +413,79 @@ Process-global capability objects may contain stateless or shared platform
mechanisms. They must not contain instance-specific peer, route,
configuration, or connectivity state.
### Native shared TUN ownership
The native composition owns `SharedVirtualNicRegistry`. Named desktop TUNs
are shared within a network namespace; mobile instances attach to one supplied
TUN descriptor. Members own address, route, and MTU claims, not the physical
device. Bootstrap route cleanup runs only when that device is created. Mobile
IPv6 claims are initialized before forwarding and follow public IPv6 updates.
Mobile VPN configuration is projected from admitted members' native claims.
Admission keeps unchanged attached members ahead of conflicting newcomers or
reconfigured members. Rejected members are reported individually, while the
remaining members stay attached. Each member's initial claims are applied as
one batch before it can open or replace the shared descriptor.
Mobile claims update dispatcher tables without configuring the OS interface;
the platform VPN owns its addresses, routes, and MTU.
If applying native claims fails, the shared NIC is invalidated and its
dispatcher is stopped so every member can recreate the device. Restoring only
the recorded claims would leave partially applied OS configuration in use.
TUN replacement and I/O failures also invalidate the shared NIC. TUN writes
have bounded waits so a stalled device cannot indefinitely block claim updates
or member cleanup.
Desktop and mobile shared TUNs use the same lazily initialized native Tokio
runtime, independent of every member's executor. It creates the device's async
I/O registration, drives dispatchers, and completes member claim cleanup even
after the member executor stops or the registration is dropped outside Tokio.
The runtime has two workers on desktop to allow independent TUN dispatchers
to run concurrently, and retains one worker on mobile. It owns no registry or
member claims; those remain with each shared NIC. Cleanup remains asynchronous,
and the last member's cleanup stops the dispatcher and releases the desktop
device. A process-lifetime runtime does not extend the device's lifetime.
Unnamed desktop TUNs and instance-local work retain their existing executors.
Each member connects through one bounded Ring Tunnel. The shared dispatcher
polls member streams in rotation and writes directly to their sinks, with no
per-member forwarding tasks or intermediate packet queues. Desktop and mobile
use one dispatch loop; mobile descriptor replacement is a control message.
The loop consumes Tokio's cooperative budget even when packets are dropped.
Only the member running the Magic DNS resolver claims its shared fake-IP
route. The claim is removed before resolver shutdown and acquired by the next
serving member after takeover. Client-only runners do not claim that route.
The dispatcher preserves desktop kernel-forwarded subnet sources. It corrects
wrong-member local sources and, on mobile, synthetic sources. Native local
flows and translated flows reserve member-scoped transport tuples so address
translation cannot merge sockets using the same port and remote endpoint.
Collisions remap the source port or ICMP Echo identifier; replies restore the
original tuple. Unrelated route updates and member removals retain mappings.
All Echo sequence numbers for one identifier share a reservation; translation
preserves the sequence field in each request and reply.
Removing an involved address or member releases its mappings. A full member
queue drops that packet without blocking other members or control messages.
With one attached member, packets go directly to that member without destination
matching or new source translations. Native flow reservations remain active so
later members cannot reuse their tuples. Existing translations continue in both
directions after other members leave. Unmodified IPv4 fragments and IPv6
extension headers pass through the single-member path unchanged.
Reservations are bounded at 4096 flows. Activity in either direction refreshes
them; table pressure reclaims reservations idle for at least 180 seconds. If
all slots remain active, new tracked flows are dropped rather than evicting a
live mapping. Desktop external-source forwarding bypasses this table. As with
other idle-expiring NAT state, very late replies after tuple reuse cannot be
distinguished from replies to the new flow.
Translation supports unfragmented IPv4 and ordinary IPv6 TCP, UDP, and ICMP
Echo traffic. IPv4 fragment and IPv6 extension-header translation remain
unsupported. ICMP errors quoting a translated flow are not reverse-translated;
in particular, a smaller downstream MTU can prevent PMTU discovery for such
flows. This limitation does not affect unmodified flows and is intentionally
retained rather than extending the dispatcher into a full ICMP/fragment NAT.
## Runtime configuration authority
`TomlConfig` is the authoritative desired configuration used for management
@@ -406,6 +502,28 @@ behavior:
- gateway and connectivity settings;
- runtime configuration patches.
Manual routing retains three distinct states: absent routes select automatic
routing, a present empty list installs no remote subnet routes, and a present
nonempty list selects explicit routes. `NetworkConfig.enable_manual_routes`
preserves this distinction on readback. `InstanceConfigPatch` applies its
optional `enable_manual_routes` together with route edits in one route
sub-patch: true retains an empty manual list and false restores automatic
routing. Omitting the mode preserves released RoutePatch behavior, including
returning to automatic routing when the edited list becomes empty. This does
not change the ordered partial-commit contract of a multi-field patch.
`HeartbeatRequest.support_manual_routes_patch` advertises this complete route
contract, including typed empty-manual readback, on the WebClient Session.
The Web controller uses the current authenticated heartbeat to choose route
patches; observed instance configuration caches contain no capability state.
Released clients omit the flag and still receive
full configuration replacements for route changes. For their ambiguous empty
readback, Web reads the existing `GetConfig.toml_config`, or, when absent, the
instance-scoped `ShowNodeInfo` TOML used by 2.6.4. It reads only the instance ID
and route field, requires a matching instance, and confirms the actual mode
before recording application. Supporting clients use typed readback directly;
compact clients with raw GetConfig need no Peer management RPC.
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.
@@ -434,6 +552,13 @@ optional `tracing` feature selects the tokio-console subscriber integration;
only that diagnostic profile pulls the main crate's `tracing-subscriber` and
`console-subscriber` dependencies.
The native CLI enables the `CORE` and `easytier_core::instance::manager` console
targets by default. Instance startup failures are reported immediately with the
instance ID and complete error chain, including the listener URL for bind
failures. When all instances stop, the CLI's failure summary retains each failed
instance's ID and saved error. Explicit console levels and `RUST_LOG` directives
still control which records are emitted.
Contrib applications and platform integrations may have independent logging
requirements and are not implicitly wired to the native process logger.
+55
View File
@@ -0,0 +1,55 @@
# Inspect credential users from the CLI
On a node that stores the issued credentials, list their currently authenticated
direct peers:
```sh
easytier-cli credential peers
# Select one network instance and return machine-readable output.
easytier-cli --instance-name my-network --output json credential peers
```
The table associates each credential ID with peer IDs, virtual IPv4/IPv6
addresses, and hostnames. A reusable credential can have multiple peers;
multiple tunnels to the same peer appear only once. Existing `credential list`
output is unchanged.
JSON output has this shape for one instance:
```json
{
"credentials": [
{
"credential_id": "user-123",
"peers": [
{
"peer_id": 42,
"ipv4": "10.144.144.2",
"ipv6": null,
"hostname": "laptop"
}
]
}
]
}
```
Without an explicit instance selector, the command follows the other CLI query
commands: it queries running instances, wrapping multiple results with their
instance IDs and names.
## Scope
The association uses the remote public key of an authenticated live credential
connection, matched against the credential's SHA-256 public-key fingerprint.
It never uses a route advertisement alone as proof of credential identity.
Credential secrets and connection keys are not printed.
This is a per-node snapshot, not a network-wide login history. An empty `peers`
array (shown as `no direct peers` in the table) does not mean a credential is
unused elsewhere: its users may only be connected to another node or reachable
through a relay. Older cores without credential fingerprints cannot be matched.
Missing route addresses are returned as `null` (shown as `-`); a peer may connect
before its route or DHCP address is available. Addresses and connection state
can change between the RPC snapshots.
+21 -4
View File
@@ -290,7 +290,11 @@ Each resource owns explicit leases:
lease through the backend connection wrapper;
- a listener owns its wildcard listener lease;
- an accepted stream owns an independent exact flow lease;
- a UDP socket owns the destination flow leases it created;
- a UDP socket owns one bound local-endpoint lease, independent of its remote
destinations; an unconnected socket can receive before its first send;
- UDP binds allocate and reserve ports within the instance, independently of
TUN and Host sockets. Bound endpoints are owned by the data plane; unmatched
traffic continues through the peer-packet pipeline;
- a direct or local stream reports its actual address and does not fabricate a
smoltcp address.
@@ -305,12 +309,25 @@ flow table using pointer equality.
Packet classification behavior must remain compatible:
- malformed or unmatched packets pass to the next pipeline;
- malformed or unmatched nonfragmented packets pass to the next pipeline;
- exact TCP flows win over wildcard listeners;
- only supported EasyTier data packet kinds are consumed;
- modified-source packets that are not local loopback traffic pass through;
- fragmented UDP response routing remains supported;
- a packet is consumed only when the owning flow exists.
- local UDP fragments follow the route selected by the first fragment's
destination port. Earlier tails wait for that decision, then continue as
original packets, without mirroring or changing packet sizes;
- a shallow cache retains up to 1,024 routes and 1 MiB of pending packet
bytes, with a one-second idle timeout. Expiry and LRU eviction discard
pending fragments; they never fall back to a different route. Each new
data-plane smoltcp stack uses a random seed to avoid replaying its IP ID
sequence. Residual 16-bit ID collisions can still reuse a cached route,
causing UDP loss or unintended downstream delivery. This tradeoff is
accepted to avoid tracking fragment ranges and completion. Cache hits
refresh the idle timeout, so repeated collisions can prolong the effect;
- a closed UDP binding cannot transfer its cached fragments to a new binding.
Address changes and runtime shutdown clear the cache;
- released batches resume at the next filter in the current pipeline snapshot;
- nonfragmented packets are consumed only when the owning flow exists.
`DataPlaneRuntime`, not the SOCKS5 Adapter, registers and owns the peer-packet
pipeline.
+1
View File
@@ -14,6 +14,7 @@ ffi-dataplane = [
"easytier/ffi-dataplane",
"easytier-core/proxy-smoltcp-stack",
]
macos-ne = ["easytier/macos-ne"]
[dependencies]
easytier = { workspace = true, default-features = true, features = ["tracing-log"] }
@@ -21,7 +21,7 @@ pub(crate) unsafe fn set_tun_fd(inst_name: *const c_char, fd: c_int) -> c_int {
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");
set_error_msg(&format!("instance not found: {inst_name}"));
return -1;
}
Err(error) => {
@@ -32,7 +32,10 @@ pub(crate) unsafe fn set_tun_fd(inst_name: *const c_char, fd: c_int) -> c_int {
match ffi_context().manager.attach_tun_fd(inst_id, fd) {
Ok(_) => 0,
Err(_) => -1,
Err(e) => {
set_error_msg(&format!("failed to set tun fd: {}", e));
-1
}
}
}
+5
View File
@@ -6,6 +6,11 @@ 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.
Download [pre-built binaries](https://github.com/EasyTier/easytier-mini/releases)
from the separate [easytier-mini repository](https://github.com/EasyTier/easytier-mini),
which handles builds and releases. The source code is maintained here in the
main EasyTier repository.
Build it with:
```sh
+1 -1
View File
@@ -20,7 +20,7 @@ easytier-proto = { workspace = true, features = [
"core",
"json-rpc",
] }
futures.workspace = true
futures = { workspace = true, features = ["default"] }
napi-derive-ohos = "1.1"
napi-ohos = { version = "1.1", default-features = false, features = [
"serde-json",
@@ -1,6 +1,8 @@
use easytier::proto::ALL_DESCRIPTOR_BYTES;
use once_cell::sync::Lazy;
use prost_reflect::{Cardinality, DescriptorPool, FieldDescriptor, Kind, MessageDescriptor};
use prost_reflect::{
Cardinality, DescriptorPool, FieldDescriptor, FileDescriptor, Kind, MessageDescriptor,
};
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
@@ -204,7 +206,15 @@ fn build_node(
}
}
fn build_map_entry_node(message_desc: &MessageDescriptor) -> NetworkConfigSchema {
/// The messages being expanded further up, by full name. The pool carries the
/// descriptor's own schema, whose messages refer back to each other, so a type
/// that reaches one it is already inside stops instead of recursing forever.
type ExpansionPath = Vec<String>;
fn build_map_entry_node(
message_desc: &MessageDescriptor,
path: &mut ExpansionPath,
) -> NetworkConfigSchema {
let key_field = message_desc.map_entry_key_field();
let value_field = message_desc.map_entry_value_field();
@@ -221,35 +231,49 @@ fn build_map_entry_node(message_desc: &MessageDescriptor) -> NetworkConfigSchema
Vec::new(),
Vec::new(),
vec![
build_schema_field_node(&key_field),
build_schema_field_node(&value_field),
build_schema_field_node(&key_field, path),
build_schema_field_node(&value_field, path),
],
Vec::new(),
)
}
fn field_children(field: &FieldDescriptor) -> Vec<NetworkConfigSchema> {
fn field_children(field: &FieldDescriptor, path: &mut ExpansionPath) -> Vec<NetworkConfigSchema> {
if field.is_map()
&& let Kind::Message(message_desc) = field.kind()
{
return vec![build_map_entry_node(&message_desc)];
return vec![build_map_entry_node(&message_desc, path)];
}
match field.kind() {
Kind::Message(message_desc) => build_message_children(&message_desc),
Kind::Message(message_desc) => build_message_children(&message_desc, path),
_ => Vec::new(),
}
}
fn build_message_children(message_desc: &MessageDescriptor) -> Vec<NetworkConfigSchema> {
message_desc
.fields()
.filter(should_expose_field)
.map(|field| build_schema_field_node(&field))
.collect()
fn build_message_children(
message_desc: &MessageDescriptor,
path: &mut ExpansionPath,
) -> Vec<NetworkConfigSchema> {
let full_name = message_desc.full_name().to_string();
if path.contains(&full_name) {
return Vec::new();
}
path.push(full_name);
let mut children = Vec::new();
for field in message_desc.fields().filter(should_expose_field) {
children.push(build_schema_field_node(&field, path));
}
path.pop();
children
}
fn build_schema_field_node(field: &FieldDescriptor) -> NetworkConfigSchema {
fn build_schema_field_node(
field: &FieldDescriptor,
path: &mut ExpansionPath,
) -> NetworkConfigSchema {
build_node(
"field",
field.name().to_string(),
@@ -262,17 +286,26 @@ fn build_schema_field_node(field: &FieldDescriptor) -> NetworkConfigSchema {
field_default_value_text(field),
enum_options(field.kind()),
build_validations(field),
field_children(field),
field_children(field, path),
Vec::new(),
)
}
/// The descriptor's own schema is not configuration surface, and its messages
/// are the ones that refer back to each other.
fn is_descriptor_schema(file: &FileDescriptor) -> bool {
file.name() == "google/protobuf/descriptor.proto"
}
fn collect_definitions() -> Vec<NetworkConfigSchema> {
let mut definitions = Vec::new();
for message_desc in descriptor_pool().all_messages() {
let full_name = message_desc.full_name();
if full_name == NETWORK_CONFIG_MESSAGE_NAME || message_desc.is_map_entry() {
if full_name == NETWORK_CONFIG_MESSAGE_NAME
|| message_desc.is_map_entry()
|| is_descriptor_schema(&message_desc.parent_file())
{
continue;
}
@@ -288,12 +321,16 @@ fn collect_definitions() -> Vec<NetworkConfigSchema> {
None,
Vec::new(),
Vec::new(),
build_message_children(&message_desc),
build_message_children(&message_desc, &mut Vec::new()),
Vec::new(),
));
}
for enum_desc in descriptor_pool().all_enums() {
if is_descriptor_schema(&enum_desc.parent_file()) {
continue;
}
definitions.push(build_node(
"enum",
enum_desc.full_name().to_string(),
@@ -329,7 +366,7 @@ fn build_network_config_schema() -> NetworkConfigSchema {
None,
Vec::new(),
Vec::new(),
build_message_children(&network_config),
build_message_children(&network_config, &mut Vec::new()),
collect_definitions(),
)
}
+30 -10
View File
@@ -18,6 +18,7 @@ crate-type = ["rlib", "cdylib"]
wasm-opt = ["-Oz", "--enable-bulk-memory", "--enable-nontrapping-float-to-int"]
[dependencies]
optionize.workspace = true
anyhow.workspace = true
ariadne = { version = "0.6", optional = true }
arc-swap.workspace = true
@@ -30,11 +31,11 @@ bytecodec.workspace = true
bytes.workspace = true
chrono = { workspace = true, features = ["clock"] }
cidr = { workspace = true, features = ["serde"] }
crossbeam.workspace = true
crossbeam-utils.workspace = true
dashmap.workspace = true
bon.workspace = true
easytier-proto = { workspace = true, features = ["core"] }
futures.workspace = true
futures = { workspace = true, features = ["std", "async-await"] }
guarden.workspace = true
hmac.workspace = true
http-body-util = { workspace = true, optional = true }
@@ -58,6 +59,14 @@ serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
sha2.workspace = true
smoltcp = { workspace = true, optional = true }
# All core Noise handshakes use 25519_ChaChaPoly_SHA256. Snow's std feature
# also enables unused ring and BLAKE2 dependencies, so use its alloc support.
snow = { version = "0.10.0", default-features = false, features = [
"use-chacha20poly1305",
"use-sha2",
"use-curve25519",
"use-getrandom",
] }
stun_codec.workspace = true
thiserror.workspace = true
tracing.workspace = true
@@ -74,25 +83,25 @@ tokio-util = { workspace = true, features = ["io", "rt"] }
tokio-rustls = { workspace = true, optional = true }
url = { workspace = true, features = ["serde"] }
wildmatch = "2.6.1"
uuid = { workspace = true, features = ["v4", "fast-rng", "serde"] }
uuid = { workspace = true, features = ["v4", "serde"] }
webpki-roots = { version = "1.0", optional = true }
x25519-dalek = { workspace = true, features = ["static_secrets"] }
zerocopy = { workspace = true, features = ["derive", "simd"] }
zstd = { version = "0.14", optional = true }
aes-gcm = { version = "0.11.1", optional = true }
chacha20poly1305 = { version = "0.11.0", optional = true }
# Match snow 0.10's AEAD generation to share aead, cipher and crypto-common.
# Upgrade together with snow rather than pulling in a second crypto stack.
aes-gcm = { version = "0.10.3", optional = true }
chacha20poly1305 = { version = "0.10.1", optional = true }
openssl = { version = "0.10", optional = true, features = ["vendored"] }
[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
getrandom-02 = { package = "getrandom", version = "0.2.17", features = ["js"] }
getrandom-03 = { package = "getrandom", version = "0.3.4", features = ["wasm_js"] }
snow = { version = "0.10.0", default-features = false, features = ["default-resolver", "default-resolver-crypto"] }
ring = { version = "0.17", features = ["wasm32_unknown_unknown_js"], optional = true }
rustls-pki-types = { version = "1.15.1", features = ["web"], optional = true }
uuid = { workspace = true, features = ["js"] }
wasm-bindgen = "0.2"
[target.'cfg(not(all(target_arch = "wasm32", target_os = "unknown")))'.dependencies]
snow = "0.10.0"
[features]
default = ["aes-gcm", "endpoint-discovery", "extended-services", "management", "tcp-hole-punch"]
aes-gcm = ["dep:aes-gcm"]
@@ -106,16 +115,20 @@ endpoint-discovery = [
"dep:http-body-util",
"dep:hyper",
"dep:hyper-util",
"dep:ring",
"dep:rustls",
"dep:rustls-pki-types",
"dep:tokio-rustls",
"dep:webpki-roots",
]
dhcp-ipv4 = []
dhcp-ipv6 = []
public-ipv6-provider = []
vpn-portal = []
wrapped-transport = []
extended-services = [
"dhcp-ipv4",
"dhcp-ipv6",
"public-ipv6-provider",
"vpn-portal",
"wrapped-transport",
@@ -152,12 +165,19 @@ test-utils = []
tracing-log = ["tracing/log"]
zstd = ["dep:zstd"]
[dev-dependencies]
futures = { workspace = true, features = ["executor"] }
# Tests read the (easytier.flag) annotations off the schema itself.
prost-reflect = "0.16.4"
[target.'cfg(not(target_os = "wasi"))'.dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "test-util"] }
[package.metadata.cargo-machete]
ignored = [
# Enable browser entropy backends for transitive rand/snow dependencies.
# Enable browser entropy backends for transitive rand/snow/AEAD dependencies.
"getrandom-02",
"getrandom-03",
# Enable browser time for rustls certificate validation.
"rustls-pki-types",
]
+12 -55
View File
@@ -4,8 +4,10 @@ use easytier_proto::api::manage::{
self, NetworkConfig, NetworkingMethod, PortForwardConfig as ApiPortForwardConfig,
};
use optionize::Optionizable as _;
use super::{
api_input::managed_credential_to_proto,
api_input::set_network_flags,
toml::{ConfigLoader as _, TomlConfig},
};
@@ -14,6 +16,12 @@ pub fn network_config_from_toml(config: &TomlConfig) -> NetworkConfig {
let mut result = NetworkConfig {
instance_id: Some(config.get_id().to_string()),
dhcp: Some(config.get_dhcp()),
dhcp_ipv4_cidr: config.get_dhcp_config().cidr().map(|cidr| cidr.to_string()),
dhcpv6: Some(config.get_dhcpv6()),
dhcp_ipv6_cidr: config
.get_dhcpv6_config()
.cidr()
.map(|cidr| cidr.to_string()),
..Default::default()
};
@@ -99,9 +107,7 @@ pub fn network_config_from_toml(config: &TomlConfig) -> NetworkConfig {
});
}
if let Some(routes) = config.get_routes()
&& !routes.is_empty()
{
if let Some(routes) = config.get_routes() {
result.enable_manual_routes = Some(true);
result.routes = routes.iter().map(ToString::to_string).collect();
}
@@ -125,61 +131,12 @@ pub fn network_config_from_toml(config: &TomlConfig) -> NetworkConfig {
result.managed_credentials = config
.get_managed_credentials()
.into_iter()
.map(managed_credential_to_proto)
.map(|credential| credential.downgrade())
.collect();
let flags = config.get_flags();
let default_flags = default_config.get_flags();
result.latency_first = Some(flags.latency_first);
result.dev_name = Some(flags.dev_name.clone());
result.use_smoltcp = Some(flags.use_smoltcp);
result.disable_ipv6 = Some(!flags.enable_ipv6);
result.enable_kcp_proxy = Some(flags.enable_kcp_proxy);
result.disable_kcp_input = Some(flags.disable_kcp_input);
result.enable_quic_proxy = Some(flags.enable_quic_proxy);
result.disable_quic_input = Some(flags.disable_quic_input);
result.disable_p2p = Some(flags.disable_p2p);
result.p2p_only = Some(flags.p2p_only);
result.lazy_p2p = Some(flags.lazy_p2p);
result.bind_device = Some(flags.bind_device);
result.socket_mark = flags.socket_mark;
result.no_tun = Some(flags.no_tun);
result.enable_exit_node = Some(flags.enable_exit_node);
result.relay_all_peer_rpc = Some(flags.relay_all_peer_rpc);
result.need_p2p = Some(flags.need_p2p);
result.multi_thread = Some(flags.multi_thread);
result.proxy_forward_by_system = Some(flags.proxy_forward_by_system);
result.disable_encryption = Some(!flags.enable_encryption);
result.disable_tcp_hole_punching = Some(flags.disable_tcp_hole_punching);
result.disable_udp_hole_punching = Some(flags.disable_udp_hole_punching);
result.disable_upnp = Some(flags.disable_upnp);
result.disable_relay_data = Some(flags.disable_relay_data);
result.prefer_peer_relay = Some(flags.prefer_peer_relay);
result.enable_udp_broadcast_relay = Some(flags.enable_udp_broadcast_relay);
result.disable_sym_hole_punching = Some(flags.disable_sym_hole_punching);
result.enable_magic_dns = Some(flags.accept_dns);
result.mtu = Some(flags.mtu as i32);
result.data_compress_algo = (flags.data_compress_algo != default_flags.data_compress_algo)
.then_some(flags.data_compress_algo);
result.encryption_algorithm = (flags.encryption_algorithm
!= default_flags.encryption_algorithm)
.then_some(flags.encryption_algorithm);
result.instance_recv_bps_limit =
(flags.instance_recv_bps_limit != u64::MAX).then_some(flags.instance_recv_bps_limit);
result.enable_private_mode = Some(flags.private_mode);
set_network_flags(&mut result, config.get_flags_patch());
result.acl = config.get_acl();
if flags.relay_network_whitelist == "*" {
result.enable_relay_network_whitelist = Some(false);
} else {
result.enable_relay_network_whitelist = Some(true);
result.relay_network_whitelist = flags
.relay_network_whitelist
.split_whitespace()
.map(ToOwned::to_owned)
.collect();
}
result
}
+462 -283
View File
@@ -3,13 +3,20 @@
use std::net::SocketAddr;
use anyhow::Context;
use easytier_proto::api::manage;
use easytier_proto::{api::manage, common::FlagsPatch};
#[cfg(all(
feature = "browser-config",
any(test, all(target_arch = "wasm32", target_os = "unknown"))
))]
use easytier_proto::common::Flags;
use optionize::{Optionizable, Optionized};
use crate::config::{
MappedListenerPolicy, normalize_secure_mode_config,
toml::{
ConfigLoader, ManagedCredentialConfig, NetworkIdentity, PeerConfig, PortForwardConfig,
TomlConfigLoader, VpnPortalClientConfig, VpnPortalConfig, gen_default_flags,
ConfigLoader, NetworkIdentity, PeerConfig, PortForwardConfig, TomlConfigLoader,
VpnPortalClientConfig, VpnPortalConfig,
},
};
@@ -51,32 +58,50 @@ pub fn add_proxy_network_to_config(
pub type NetworkingMethod = easytier_proto::api::manage::NetworkingMethod;
pub type NetworkConfig = easytier_proto::api::manage::NetworkConfig;
pub(crate) fn managed_credential_from_proto(
credential: &manage::ManagedCredentialConfig,
) -> ManagedCredentialConfig {
ManagedCredentialConfig {
credential_id: credential.credential_id.clone(),
credential_secret: credential.credential_secret.clone(),
groups: credential.groups.clone(),
allow_relay: credential.allow_relay,
allowed_proxy_cidrs: credential.allowed_proxy_cidrs.clone(),
expiry_unix: credential.expiry_unix,
reusable: credential.reusable.unwrap_or(true),
}
}
pub(crate) fn set_network_flags(result: &mut NetworkConfig, flags: FlagsPatch) {
result.latency_first = flags.latency_first;
result.dev_name = flags.dev_name;
result.use_smoltcp = flags.use_smoltcp;
result.disable_ipv6 = flags.enable_ipv6.map(|enabled| !enabled);
result.enable_kcp_proxy = flags.enable_kcp_proxy;
result.disable_kcp_input = flags.disable_kcp_input;
result.enable_quic_proxy = flags.enable_quic_proxy;
result.disable_quic_input = flags.disable_quic_input;
result.disable_p2p = flags.disable_p2p;
result.p2p_only = flags.p2p_only;
result.lazy_p2p = flags.lazy_p2p;
result.bind_device = flags.bind_device;
result.socket_mark = flags.socket_mark;
result.no_tun = flags.no_tun;
result.enable_exit_node = flags.enable_exit_node;
result.relay_all_peer_rpc = flags.relay_all_peer_rpc;
result.need_p2p = flags.need_p2p;
result.multi_thread = flags.multi_thread;
result.proxy_forward_by_system = flags.proxy_forward_by_system;
result.disable_encryption = flags.enable_encryption.map(|enabled| !enabled);
result.disable_tcp_hole_punching = flags.disable_tcp_hole_punching;
result.disable_udp_hole_punching = flags.disable_udp_hole_punching;
result.disable_upnp = flags.disable_upnp;
result.disable_relay_data = flags.disable_relay_data;
result.prefer_peer_relay = flags.prefer_peer_relay;
result.enable_udp_broadcast_relay = flags.enable_udp_broadcast_relay;
result.disable_sym_hole_punching = flags.disable_sym_hole_punching;
result.enable_magic_dns = flags.accept_dns;
result.mtu = flags.mtu.map(|mtu| mtu as i32);
result.data_compress_algo = flags.data_compress_algo;
result.encryption_algorithm = flags.encryption_algorithm;
result.instance_recv_bps_limit = flags.instance_recv_bps_limit;
result.enable_private_mode = flags.private_mode;
pub(crate) fn managed_credential_to_proto(
credential: ManagedCredentialConfig,
) -> manage::ManagedCredentialConfig {
manage::ManagedCredentialConfig {
credential_id: credential.credential_id,
credential_secret: credential.credential_secret,
groups: credential.groups,
allow_relay: credential.allow_relay,
allowed_proxy_cidrs: credential.allowed_proxy_cidrs,
expiry_unix: credential.expiry_unix,
reusable: Some(credential.reusable),
}
result.enable_relay_network_whitelist = flags
.relay_network_whitelist
.as_ref()
.map(|list| list != "*");
result.relay_network_whitelist = flags
.relay_network_whitelist
.filter(|list| list != "*")
.map(|list| list.split_whitespace().map(ToOwned::to_owned).collect())
.unwrap_or_default();
}
pub trait NetworkConfigExt {
@@ -96,6 +121,7 @@ const FORM_MANAGED_TOML_FIELDS: &[&str] = &[
"ipv6_public_addr_auto",
"ipv6_public_addr_prefix",
"dhcp",
"dhcpv6",
"network_identity",
"listeners",
"mapped_listeners",
@@ -112,47 +138,6 @@ const FORM_MANAGED_TOML_FIELDS: &[&str] = &[
"managed_credentials",
];
#[cfg(all(
feature = "browser-config",
any(test, all(target_arch = "wasm32", target_os = "unknown"))
))]
const FORM_MANAGED_FLAG_FIELDS: &[&str] = &[
"latency_first",
"dev_name",
"use_smoltcp",
"enable_ipv6",
"enable_kcp_proxy",
"disable_kcp_input",
"enable_quic_proxy",
"disable_quic_input",
"disable_p2p",
"p2p_only",
"lazy_p2p",
"bind_device",
"socket_mark",
"no_tun",
"enable_exit_node",
"relay_all_peer_rpc",
"need_p2p",
"multi_thread",
"proxy_forward_by_system",
"enable_encryption",
"relay_network_whitelist",
"disable_tcp_hole_punching",
"disable_udp_hole_punching",
"disable_upnp",
"disable_relay_data",
"prefer_peer_relay",
"enable_udp_broadcast_relay",
"disable_sym_hole_punching",
"accept_dns",
"mtu",
"instance_recv_bps_limit",
"private_mode",
"encryption_algorithm",
"data_compress_algo",
];
#[cfg(all(
feature = "browser-config",
any(test, all(target_arch = "wasm32", target_os = "unknown"))
@@ -184,7 +169,22 @@ pub(crate) fn merge_network_config_toml(
.remove("flags")
.and_then(|value| value.try_into().ok())
.unwrap_or_default();
for key in FORM_MANAGED_FLAG_FIELDS {
// Normalize any camelCase aliases in original [flags] to their canonical field names
// so each field appears at most once in the merged table.
for field in Flags::flags() {
if let (Some(name), Some(json_name)) = (field.name.as_deref(), field.json_name.as_deref()) {
if name != json_name
&& let Some(val) = merged_flags.remove(json_name)
{
merged_flags.entry(name.to_owned()).or_insert(val);
}
}
}
// The keys a config form owns are the flags it offers a control for, which
// the schema declares with `(easytier.flag)`.
for key in Flags::form() {
if let Some(value) = generated_flags.get(*key) {
merged_flags.insert((*key).to_owned(), value.clone());
} else {
@@ -251,7 +251,18 @@ impl NetworkConfigExt for NetworkConfig {
.with_context(|| format!("failed to parse instance id: {:?}", self.instance_id))?,
);
cfg.set_hostname(self.hostname.clone());
cfg.set_dhcp(self.dhcp.unwrap_or_default());
cfg.set_dhcp(self.dhcp.unwrap_or(self.dhcp_ipv4_cidr.is_some()));
if cfg.get_dhcp()
&& let Some(cidr) = &self.dhcp_ipv4_cidr
{
cfg.set_dhcp_config(super::DhcpConfig::from_cidr(cidr)?);
}
cfg.set_dhcpv6(self.dhcpv6.unwrap_or(self.dhcp_ipv6_cidr.is_some()));
if cfg.get_dhcpv6()
&& let Some(cidr) = &self.dhcp_ipv6_cidr
{
cfg.set_dhcpv6_config(super::DhcpConfig::from_cidr(cidr)?);
}
cfg.set_inst_name(self.network_name.clone().unwrap_or_default());
// The web UI does not expose credential inputs directly, but imported/saved
@@ -444,8 +455,8 @@ impl NetworkConfigExt for NetworkConfig {
cfg.set_managed_credentials(
self.managed_credentials
.iter()
.map(managed_credential_from_proto)
.collect(),
.map(|credential| credential.clone().upgrade())
.collect::<Result<Vec<_>, _>>()?,
);
if let Some(credential_secret) = credential_secret {
@@ -465,19 +476,6 @@ impl NetworkConfigExt for NetworkConfig {
);
}
let mut flags = gen_default_flags();
if let Some(latency_first) = self.latency_first {
flags.latency_first = latency_first;
}
if let Some(dev_name) = self.dev_name.clone() {
flags.dev_name = dev_name;
}
if let Some(use_smoltcp) = self.use_smoltcp {
flags.use_smoltcp = use_smoltcp;
}
if let Some(ipv6_public_addr_provider) = self.ipv6_public_addr_provider {
cfg.set_ipv6_public_addr_provider(ipv6_public_addr_provider);
}
@@ -496,145 +494,59 @@ impl NetworkConfigExt for NetworkConfig {
)?));
}
if let Some(disable_ipv6) = self.disable_ipv6 {
flags.enable_ipv6 = !disable_ipv6;
}
if let Some(enable_kcp_proxy) = self.enable_kcp_proxy {
flags.enable_kcp_proxy = enable_kcp_proxy;
}
if let Some(disable_kcp_input) = self.disable_kcp_input {
flags.disable_kcp_input = disable_kcp_input;
}
if let Some(enable_quic_proxy) = self.enable_quic_proxy {
flags.enable_quic_proxy = enable_quic_proxy;
}
if let Some(disable_quic_input) = self.disable_quic_input {
flags.disable_quic_input = disable_quic_input;
}
if let Some(disable_p2p) = self.disable_p2p {
flags.disable_p2p = disable_p2p;
}
if let Some(p2p_only) = self.p2p_only {
flags.p2p_only = p2p_only;
}
if let Some(lazy_p2p) = self.lazy_p2p {
flags.lazy_p2p = lazy_p2p;
}
if let Some(bind_device) = self.bind_device {
flags.bind_device = bind_device;
}
if self.socket_mark.is_some() {
flags.socket_mark = self.socket_mark;
}
if let Some(no_tun) = self.no_tun {
flags.no_tun = no_tun;
}
if let Some(enable_exit_node) = self.enable_exit_node {
flags.enable_exit_node = enable_exit_node;
}
if let Some(relay_all_peer_rpc) = self.relay_all_peer_rpc {
flags.relay_all_peer_rpc = relay_all_peer_rpc;
}
if let Some(need_p2p) = self.need_p2p {
flags.need_p2p = need_p2p;
}
if let Some(multi_thread) = self.multi_thread {
flags.multi_thread = multi_thread;
}
if let Some(proxy_forward_by_system) = self.proxy_forward_by_system {
flags.proxy_forward_by_system = proxy_forward_by_system;
}
if let Some(disable_encryption) = self.disable_encryption {
flags.enable_encryption = !disable_encryption;
}
if self.enable_relay_network_whitelist.unwrap_or_default() {
if !self.relay_network_whitelist.is_empty() {
flags.relay_network_whitelist = self.relay_network_whitelist.join(" ");
} else {
flags.relay_network_whitelist = "".to_string();
}
}
if let Some(disable_tcp_hole_punching) = self.disable_tcp_hole_punching {
flags.disable_tcp_hole_punching = disable_tcp_hole_punching;
}
if let Some(disable_udp_hole_punching) = self.disable_udp_hole_punching {
flags.disable_udp_hole_punching = disable_udp_hole_punching;
}
if let Some(disable_upnp) = self.disable_upnp {
flags.disable_upnp = disable_upnp;
}
if let Some(disable_relay_data) = self.disable_relay_data {
flags.disable_relay_data = disable_relay_data;
}
if let Some(prefer_peer_relay) = self.prefer_peer_relay {
flags.prefer_peer_relay = prefer_peer_relay;
}
if let Some(enable_udp_broadcast_relay) = self.enable_udp_broadcast_relay {
flags.enable_udp_broadcast_relay = enable_udp_broadcast_relay;
}
if let Some(disable_sym_hole_punching) = self.disable_sym_hole_punching {
flags.disable_sym_hole_punching = disable_sym_hole_punching;
}
if let Some(enable_magic_dns) = self.enable_magic_dns {
flags.accept_dns = enable_magic_dns;
}
if let Some(mtu) = self.mtu {
flags.mtu = mtu as u32;
}
if let Some(instance_recv_bps_limit) = self.instance_recv_bps_limit {
flags.instance_recv_bps_limit = instance_recv_bps_limit;
}
if let Some(enable_private_mode) = self.enable_private_mode {
flags.private_mode = enable_private_mode;
}
if let Some(encryption_algorithm) = self.encryption_algorithm.clone() {
flags.encryption_algorithm = encryption_algorithm;
}
if let Some(acl) = self.acl.as_ref()
&& !acl.is_empty()
{
cfg.set_acl(Some(acl.clone()));
}
if let Some(data_compress_algo) = self.data_compress_algo {
if data_compress_algo < 1 {
flags.data_compress_algo = 1;
} else {
flags.data_compress_algo = data_compress_algo
}
}
cfg.set_flags(flags);
cfg.patch_flags(FlagsPatch {
dev_name: self.dev_name.clone(),
enable_ipv6: self.disable_ipv6.map(|disabled| !disabled),
socket_mark: self.socket_mark,
enable_encryption: self.disable_encryption.map(|disabled| !disabled),
relay_network_whitelist: self.enable_relay_network_whitelist.map(|enabled| {
if enabled {
self.relay_network_whitelist.join(" ")
} else {
"*".to_owned()
}
}),
accept_dns: self.enable_magic_dns,
mtu: self
.mtu
.map(u32::try_from)
.transpose()
.context("invalid mtu: expected a non-negative integer")?,
private_mode: self.enable_private_mode,
encryption_algorithm: self.encryption_algorithm.clone(),
data_compress_algo: self.data_compress_algo.map(|algorithm| algorithm.max(1)),
latency_first: self.latency_first,
use_smoltcp: self.use_smoltcp,
enable_kcp_proxy: self.enable_kcp_proxy,
disable_kcp_input: self.disable_kcp_input,
enable_quic_proxy: self.enable_quic_proxy,
disable_quic_input: self.disable_quic_input,
disable_p2p: self.disable_p2p,
p2p_only: self.p2p_only,
lazy_p2p: self.lazy_p2p,
bind_device: self.bind_device,
no_tun: self.no_tun,
enable_exit_node: self.enable_exit_node,
relay_all_peer_rpc: self.relay_all_peer_rpc,
need_p2p: self.need_p2p,
multi_thread: self.multi_thread,
proxy_forward_by_system: self.proxy_forward_by_system,
disable_tcp_hole_punching: self.disable_tcp_hole_punching,
disable_udp_hole_punching: self.disable_udp_hole_punching,
disable_upnp: self.disable_upnp,
disable_relay_data: self.disable_relay_data,
prefer_peer_relay: self.prefer_peer_relay,
enable_udp_broadcast_relay: self.enable_udp_broadcast_relay,
disable_sym_hole_punching: self.disable_sym_hole_punching,
instance_recv_bps_limit: self.instance_recv_bps_limit,
..Default::default()
});
Ok(cfg)
}
@@ -651,6 +563,12 @@ impl NetworkConfigExt for NetworkConfig {
}
result.dhcp = Some(config.get_dhcp());
result.dhcp_ipv4_cidr = config.get_dhcp_config().cidr().map(|cidr| cidr.to_string());
result.dhcpv6 = Some(config.get_dhcpv6());
result.dhcp_ipv6_cidr = config
.get_dhcpv6_config()
.cidr()
.map(|cidr| cidr.to_string());
let network_identity = config.get_network_identity();
result.network_name = Some(network_identity.network_name.clone());
@@ -735,9 +653,7 @@ impl NetworkConfigExt for NetworkConfig {
});
}
if let Some(routes) = config.get_routes()
&& !routes.is_empty()
{
if let Some(routes) = config.get_routes() {
result.enable_manual_routes = Some(true);
result.routes = routes.iter().map(|r| r.to_string()).collect();
}
@@ -764,65 +680,11 @@ impl NetworkConfigExt for NetworkConfig {
result.managed_credentials = config
.get_managed_credentials()
.into_iter()
.map(managed_credential_to_proto)
.map(|credential| credential.downgrade())
.collect();
let flags = config.get_flags();
let default_flags = default_config.get_flags();
result.latency_first = Some(flags.latency_first);
result.dev_name = Some(flags.dev_name.clone());
result.use_smoltcp = Some(flags.use_smoltcp);
result.disable_ipv6 = Some(!flags.enable_ipv6);
result.enable_kcp_proxy = Some(flags.enable_kcp_proxy);
result.disable_kcp_input = Some(flags.disable_kcp_input);
result.enable_quic_proxy = Some(flags.enable_quic_proxy);
result.disable_quic_input = Some(flags.disable_quic_input);
result.disable_p2p = Some(flags.disable_p2p);
result.p2p_only = Some(flags.p2p_only);
result.lazy_p2p = Some(flags.lazy_p2p);
result.bind_device = Some(flags.bind_device);
result.socket_mark = flags.socket_mark;
result.no_tun = Some(flags.no_tun);
result.enable_exit_node = Some(flags.enable_exit_node);
result.relay_all_peer_rpc = Some(flags.relay_all_peer_rpc);
result.need_p2p = Some(flags.need_p2p);
result.multi_thread = Some(flags.multi_thread);
result.proxy_forward_by_system = Some(flags.proxy_forward_by_system);
result.disable_encryption = Some(!flags.enable_encryption);
result.disable_tcp_hole_punching = Some(flags.disable_tcp_hole_punching);
result.disable_udp_hole_punching = Some(flags.disable_udp_hole_punching);
result.disable_upnp = Some(flags.disable_upnp);
result.disable_relay_data = Some(flags.disable_relay_data);
result.prefer_peer_relay = Some(flags.prefer_peer_relay);
result.enable_udp_broadcast_relay = Some(flags.enable_udp_broadcast_relay);
result.disable_sym_hole_punching = Some(flags.disable_sym_hole_punching);
result.enable_magic_dns = Some(flags.accept_dns);
result.mtu = Some(flags.mtu as i32);
result.data_compress_algo = (flags.data_compress_algo != default_flags.data_compress_algo)
.then_some(flags.data_compress_algo);
result.encryption_algorithm = (flags.encryption_algorithm
!= default_flags.encryption_algorithm)
.then_some(flags.encryption_algorithm.clone());
result.instance_recv_bps_limit =
(flags.instance_recv_bps_limit != u64::MAX).then_some(flags.instance_recv_bps_limit);
result.enable_private_mode = Some(flags.private_mode);
set_network_flags(&mut result, config.get_flags_patch());
result.acl = config.get_acl();
if flags.relay_network_whitelist == "*" {
result.enable_relay_network_whitelist = Some(false);
} else {
result.enable_relay_network_whitelist = Some(true);
if flags.relay_network_whitelist.is_empty() {
result.relay_network_whitelist = vec![];
} else {
result.relay_network_whitelist = flags
.relay_network_whitelist
.split_whitespace()
.map(|s| s.to_string())
.collect();
}
}
Ok(result)
}
}
@@ -832,6 +694,7 @@ mod tests {
#![allow(deprecated)]
use super::*;
use easytier_proto::common::Flags;
fn api_portal_config() -> manage::VpnPortalConfig {
manage::VpnPortalConfig {
@@ -853,6 +716,219 @@ mod tests {
}
}
#[test]
fn manual_routes_preserve_empty_and_automatic_modes() {
for routes in [Vec::new(), vec!["10.20.0.0/24".to_owned()]] {
for enabled in [None, Some(false), Some(true)] {
let mut input = standalone_config();
input.enable_manual_routes = enabled;
input.routes = routes.clone();
let config = input.gen_config().unwrap();
let expected = if enabled == Some(true) {
Some(routes.iter().map(|route| route.parse().unwrap()).collect())
} else {
None
};
assert_eq!(config.get_routes(), expected);
let readback = NetworkConfig::new_from_config(config.clone()).unwrap();
assert_eq!(
readback.enable_manual_routes,
enabled.filter(|value| *value)
);
assert_eq!(readback.gen_config().unwrap().get_routes(), expected);
assert_eq!(
TomlConfigLoader::new_from_str(&config.dump())
.unwrap()
.get_routes(),
expected
);
#[cfg(feature = "web-client")]
{
let projected = super::super::api::network_config_from_toml(&config);
assert_eq!(
projected.enable_manual_routes,
readback.enable_manual_routes
);
assert_eq!(projected.routes, readback.routes);
}
}
}
}
#[test]
fn dhcp_cidr_survives_api_and_toml_round_trips() {
let mut input = standalone_config();
input.dhcp_ipv4_cidr = Some("192.168.100.0/24".to_owned());
for enabled in [None, Some(true), Some(false)] {
input.dhcp = enabled;
let config = input.gen_config().unwrap();
assert_eq!(config.get_dhcp(), enabled.unwrap_or(true));
let readback = NetworkConfig::new_from_config(config.clone()).unwrap();
let expected_cidr = if enabled == Some(false) {
None
} else {
input.dhcp_ipv4_cidr.clone()
};
assert_eq!(readback.dhcp_ipv4_cidr, expected_cidr);
assert_eq!(
TomlConfigLoader::new_from_str(&config.dump())
.unwrap()
.get_dhcp_config(),
readback.gen_config().unwrap().get_dhcp_config()
);
#[cfg(feature = "web-client")]
assert_eq!(
super::super::api::network_config_from_toml(&config).dhcp_ipv4_cidr,
expected_cidr
);
}
}
#[test]
fn dhcpv6_cidr_survives_management_round_trips() {
let mut input = NetworkConfig {
dhcp: Some(true),
dhcp_ipv6_cidr: Some("fd12::/64".to_owned()),
..standalone_config()
};
for enabled in [None, Some(true), Some(false)] {
input.dhcpv6 = enabled;
let config = input.gen_config().unwrap();
assert_eq!(config.get_dhcpv6(), enabled.unwrap_or(true));
let readback = NetworkConfig::new_from_config(&config).unwrap();
assert_eq!(readback.dhcpv6, Some(enabled.unwrap_or(true)));
assert_eq!(
readback.dhcp_ipv6_cidr,
if enabled == Some(false) {
None
} else {
input.dhcp_ipv6_cidr.clone()
}
);
assert_eq!(
readback.gen_config().unwrap().get_dhcpv6_config(),
config.get_dhcpv6_config()
);
assert_eq!(
super::super::api::network_config_from_toml(&config).dhcp_ipv6_cidr,
readback.dhcp_ipv6_cidr
);
}
}
#[test]
fn dhcp_api_rejects_invalid_or_unusable_subnets() {
let mut input = standalone_config();
for value in [
"true",
"false",
"yes",
"::/64",
"192.168.100.0/31",
"192.168.100.1/32",
] {
input.dhcp_ipv4_cidr = Some(value.to_owned());
assert!(input.gen_config().is_err(), "accepted {value}");
}
}
/// The management API's name for a flag is the schema's to state, and the
/// projection is what actually names the fields. Turning the flag the
/// annotation names must turn the field it points at, in the direction the
/// annotation declares.
#[test]
fn declared_api_names_match_the_projection() {
use prost_reflect::{DescriptorPool, Value};
let pool = DescriptorPool::decode(easytier_proto::ALL_DESCRIPTOR_BYTES).unwrap();
let flags = pool.get_message_by_name("common.Flags").unwrap();
let extension = pool.get_extension_by_name("easytier.flag").unwrap();
// A patch states every field, so the values survive serialization:
// protobuf JSON omits a field holding its zero value.
let patch: FlagsPatch = Flags::defaults().downgrade();
let defaults = serde_json::to_value(&patch).unwrap();
for field in flags.fields() {
let name = field.name();
let options = field.options();
let annotation = options.get_extension(&extension).into_owned();
let Value::Message(meta) = annotation else {
panic!("{name}: the annotation is not a message");
};
if !meta.has_field_by_name("api") {
continue;
}
let Value::Message(spelling) = meta.get_field_by_name("api").unwrap().into_owned()
else {
panic!("{name}: the api spelling is not a message");
};
let api_field = match spelling.get_field_by_name("field").as_deref() {
Some(Value::String(api_field)) if !api_field.is_empty() => api_field.clone(),
_ => continue,
};
let negate = matches!(
spelling.get_field_by_name("negate").as_deref(),
Some(Value::Bool(true))
);
let declared = defaults[name]
.as_bool()
.unwrap_or_else(|| panic!("{name} is not a boolean flag"));
let mut flipped = defaults.clone();
flipped[name] = serde_json::json!(!declared);
let patch: FlagsPatch = serde_json::from_value(flipped).unwrap();
let mut projected = NetworkConfig::default();
set_network_flags(&mut projected, patch);
// Protobuf JSON omits a field holding its zero value.
let projected = serde_json::to_value(&projected).unwrap();
let value = projected[&api_field].as_bool().unwrap_or(false);
assert_eq!(
value,
if negate { declared } else { !declared },
"flipping {name} should leave {api_field} as the annotation declares"
);
}
}
#[cfg(feature = "config-write")]
#[test]
fn api_flags_round_trip_preserves_missing_default_and_custom_values() {
for disabled in [None, Some(false), Some(true)] {
for whitelist in [None, Some(false), Some(true)] {
let input = NetworkConfig {
disable_encryption: disabled,
enable_relay_network_whitelist: whitelist,
latency_first: Some(false),
mtu: Some(0),
instance_recv_bps_limit: Some(u64::MAX),
..standalone_config()
};
let config = input.gen_config().unwrap();
let config = TomlConfigLoader::new_from_str(&config.dump()).unwrap();
let output = NetworkConfig::new_from_config(&config).unwrap();
assert_eq!(output.disable_encryption, disabled);
assert_eq!(output.enable_relay_network_whitelist, whitelist);
assert_eq!(output.latency_first, Some(false));
assert_eq!(output.mtu, Some(0));
assert_eq!(output.instance_recv_bps_limit, Some(u64::MAX));
assert_eq!(output.disable_ipv6, None);
assert_eq!(output.encryption_algorithm, None);
}
}
assert!(
NetworkConfig {
mtu: Some(-1),
..standalone_config()
}
.gen_config()
.is_err()
);
}
#[test]
fn vpn_portal_api_config_round_trips_through_toml_model() {
let input = NetworkConfig {
@@ -969,6 +1045,46 @@ mod tests {
assert!(config.get_vpn_portal_config().is_none());
}
#[cfg(feature = "browser-config")]
#[test]
fn browser_merge_reconciles_renamed_flags() {
let original = r#"
[flags]
default_protocol = "udp"
enable_encryption = false
enable_ipv6 = false
accept_dns = false
private_mode = false
"#;
for value in [Some(false), Some(true), None] {
let network_config = NetworkConfig {
disable_encryption: value,
disable_ipv6: value,
enable_magic_dns: value,
enable_private_mode: value,
..standalone_config()
};
let merged = merge_network_config_toml(original, &network_config).unwrap();
let merged: toml::Table = toml::from_str(&merged).unwrap();
let flags = merged["flags"].as_table().unwrap();
assert_eq!(flags["default_protocol"].as_str(), Some("udp"));
for (field, expected) in [
("enable_encryption", value.map(|disabled| !disabled)),
("enable_ipv6", value.map(|disabled| !disabled)),
("accept_dns", value),
("private_mode", value),
] {
assert_eq!(
flags.get(field).and_then(toml::Value::as_bool),
expected,
"merging {field} from API value {value:?}"
);
}
}
}
#[cfg(feature = "browser-config")]
#[test]
fn browser_merge_preserves_fields_outside_the_shared_form() {
@@ -1006,11 +1122,74 @@ disable_p2p = true
Some("edited-network")
);
assert_eq!(merged["flags"]["default_protocol"].as_str(), Some("udp"));
assert!(
!merged["flags"]
.as_table()
.unwrap()
.contains_key("disable_p2p")
assert_eq!(merged["flags"]["disable_p2p"].as_bool(), Some(false));
}
#[cfg(feature = "browser-config")]
#[test]
fn browser_merge_normalizes_camel_case_flag_aliases() {
let original = r#"
[flags]
disableP2p = true
enableEncryption = false
foreignRelayBpsLimit = 1234
"#;
let parsed_orig = TomlConfigLoader::new_from_str(original).unwrap();
let mut network_config = NetworkConfig::new_from_config(&parsed_orig).unwrap();
network_config.disable_p2p = Some(false);
let merged = merge_network_config_toml(original, &network_config).unwrap();
let parsed = TomlConfigLoader::new_from_str(&merged)
.expect("merged config should be parseable without duplicate-field error");
assert!(!parsed.get_flags().disable_p2p);
assert!(!parsed.get_flags().enable_encryption);
assert_eq!(parsed.get_flags().foreign_relay_bps_limit, 1234);
let merged_table: toml::Table = toml::from_str(&merged).unwrap();
let flags = merged_table["flags"].as_table().unwrap();
assert_eq!(
flags.get("disable_p2p").and_then(toml::Value::as_bool),
Some(false)
);
assert!(flags.get("disableP2p").is_none());
assert_eq!(
flags
.get("enable_encryption")
.and_then(toml::Value::as_bool),
Some(false)
);
assert!(flags.get("enableEncryption").is_none());
assert_eq!(
flags
.get("foreign_relay_bps_limit")
.and_then(toml::Value::as_integer),
Some(1234)
);
assert!(flags.get("foreignRelayBpsLimit").is_none());
}
#[cfg(feature = "browser-config")]
#[test]
fn browser_merge_repro_camel_case_disable_p2p_collision() {
let original = r#"
[flags]
disableP2p = false
"#;
let mut network_config = standalone_config();
network_config.hostname = Some("new-hostname".to_owned());
network_config.disable_p2p = Some(false);
let merged = merge_network_config_toml(original, &network_config).unwrap();
let parsed = TomlConfigLoader::new_from_str(&merged)
.expect("saving config with camelCase alias must not fail on reload");
assert!(!parsed.get_flags().disable_p2p);
let merged_table: toml::Table = toml::from_str(&merged).unwrap();
let flags = merged_table["flags"].as_table().unwrap();
assert_eq!(
flags.get("disable_p2p").and_then(toml::Value::as_bool),
Some(false)
);
assert!(flags.get("disableP2p").is_none());
}
}
+96
View File
@@ -0,0 +1,96 @@
use std::{fmt, marker::PhantomData, str::FromStr};
use anyhow::Context;
use cidr::{Cidr, Family, Ipv4Cidr, Ipv6Cidr};
use serde::{Deserialize, Deserializer, Serialize, de};
/// DHCP enablement and an optional subnet used before peers advertise one.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(untagged)]
pub enum DhcpConfig<C = Ipv4Cidr> {
Enabled(bool),
Cidr(C),
}
impl<C> Default for DhcpConfig<C> {
fn default() -> Self {
Self::Enabled(false)
}
}
impl<C: Cidr> DhcpConfig<C> {
pub fn enabled(self) -> bool {
!matches!(self, Self::Enabled(false))
}
pub fn cidr(self) -> Option<C> {
match self {
Self::Enabled(_) => None,
Self::Cidr(cidr) => Some(cidr),
}
}
pub fn from_cidr(value: &str) -> anyhow::Result<Self> {
let cidr: C = value
.parse()
.with_context(|| format!("invalid DHCP CIDR: {value}"))?;
match cidr.family() {
Family::Ipv4 => anyhow::ensure!(
cidr.network_length() <= 30,
"DHCP IPv4 CIDR must have usable host addresses (prefix length 0..=30)"
),
Family::Ipv6 => {
let subnet: Ipv6Cidr = value.parse()?;
anyhow::ensure!(
valid_ipv6_subnet(subnet),
"DHCP IPv6 CIDR must be a unicast subnet within fc00::/7 or 2000::/3, with prefix length at most 126"
);
}
}
Ok(Self::Cidr(cidr))
}
}
impl<C: Cidr> FromStr for DhcpConfig<C> {
type Err = anyhow::Error;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.parse::<bool>() {
Ok(enabled) => Ok(Self::Enabled(enabled)),
Err(_) => Self::from_cidr(value),
}
}
}
impl<'de, C: Cidr> Deserialize<'de> for DhcpConfig<C> {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct DhcpVisitor<C>(PhantomData<C>);
impl<C: Cidr> de::Visitor<'_> for DhcpVisitor<C> {
type Value = DhcpConfig<C>;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("a boolean or a usable DHCP CIDR")
}
fn visit_bool<E: de::Error>(self, value: bool) -> Result<Self::Value, E> {
Ok(DhcpConfig::Enabled(value))
}
fn visit_str<E: de::Error>(self, value: &str) -> Result<Self::Value, E> {
DhcpConfig::from_cidr(value).map_err(E::custom)
}
}
deserializer.deserialize_any(DhcpVisitor(PhantomData))
}
}
// Only overlay unicast subnets are eligible for automatic assignment. Keep at
// least two candidates after reserving the subnet endpoints.
pub(crate) fn valid_ipv6_subnet(subnet: Ipv6Cidr) -> bool {
let first = subnet.first_address().segments()[0];
subnet.network_length() <= 126
&& ((subnet.network_length() >= 7 && first & 0xfe00 == 0xfc00)
|| (subnet.network_length() >= 3 && first & 0xe000 == 0x2000))
}
+45 -34
View File
@@ -1,48 +1,59 @@
use std::{fmt, str::FromStr};
use serde::{Deserialize, Serialize};
use strum::VariantArray;
/// Stable configuration vocabulary for every known encryption algorithm.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, VariantArray)]
#[derive(
Debug,
Default,
Clone,
Copy,
PartialEq,
Eq,
VariantArray,
strum::Display,
strum::EnumString,
strum::IntoStaticStr,
strum::AsRefStr,
Serialize,
Deserialize,
)]
#[strum(ascii_case_insensitive)]
pub enum EncryptionAlgorithm {
#[strum(to_string = "xor", serialize = "xor")]
#[serde(rename = "xor")]
Xor,
#[default]
#[strum(
to_string = "aes-gcm",
serialize = "aes-gcm",
serialize = "openssl-aes-gcm"
)]
#[serde(rename = "aes-gcm", alias = "openssl-aes-gcm")]
AesGcm,
#[strum(
to_string = "aes-256-gcm",
serialize = "aes-256-gcm",
serialize = "openssl-aes-256-gcm"
)]
#[serde(rename = "aes-256-gcm", alias = "openssl-aes-256-gcm")]
Aes256Gcm,
#[strum(
to_string = "chacha20",
serialize = "chacha20",
serialize = "chacha20-poly1305",
serialize = "openssl-chacha20"
)]
#[serde(
rename = "chacha20",
alias = "chacha20-poly1305",
alias = "openssl-chacha20"
)]
ChaCha20,
}
impl EncryptionAlgorithm {
pub const fn as_str(self) -> &'static str {
match self {
Self::Xor => "xor",
Self::AesGcm => "aes-gcm",
Self::Aes256Gcm => "aes-256-gcm",
Self::ChaCha20 => "chacha20",
}
}
}
impl fmt::Display for EncryptionAlgorithm {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for EncryptionAlgorithm {
type Err = ();
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.to_ascii_lowercase().as_str() {
"xor" => Ok(Self::Xor),
"aes-gcm" | "openssl-aes-gcm" => Ok(Self::AesGcm),
"aes-256-gcm" | "openssl-aes-256-gcm" => Ok(Self::Aes256Gcm),
"chacha20" | "chacha20-poly1305" | "openssl-chacha20" => Ok(Self::ChaCha20),
_ => Err(()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
+10 -17
View File
@@ -10,12 +10,15 @@ pub mod api_input;
target_os = "unknown"
))]
mod browser;
mod dhcp;
mod encryption;
pub mod gateway;
pub mod peers;
pub mod runtime;
pub mod toml;
pub use dhcp::DhcpConfig;
pub(crate) use dhcp::valid_ipv6_subnet;
pub use encryption::EncryptionAlgorithm;
pub(crate) const DEFAULT_UDP_STUN_SERVERS: &[&str] = &[
@@ -48,6 +51,7 @@ use std::{
use anyhow::Context as _;
use base64::{Engine as _, prelude::BASE64_STANDARD};
use easytier_proto::{common as common_pb, core_config as pb};
use optionize::Optionizable;
use serde::{Deserialize, Serialize};
use url::Url;
@@ -280,6 +284,8 @@ pub struct ForeignNetworkConfig {
pub cidrs: Vec<IpPrefix>,
}
#[optionize::optionized]
#[optionize(object = pb::PeerPolicyConfig)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeerPolicyConfig {
pub p2p_enabled: bool,
@@ -522,28 +528,15 @@ impl From<ForeignNetworkConfig> for pb::ForeignNetworkConfig {
impl From<pb::PeerPolicyConfig> for PeerPolicyConfig {
fn from(value: pb::PeerPolicyConfig) -> Self {
let default = Self::default();
Self {
p2p_enabled: value.p2p_enabled.unwrap_or(default.p2p_enabled),
relay_peer_rpc: value.relay_peer_rpc.unwrap_or(default.relay_peer_rpc),
relay_data: value.relay_data.unwrap_or(default.relay_data),
latency_first: value.latency_first.unwrap_or(default.latency_first),
encryption_required: value
.encryption_required
.unwrap_or(default.encryption_required),
}
let mut config = Self::default();
config.load(value);
config
}
}
impl From<PeerPolicyConfig> for pb::PeerPolicyConfig {
fn from(value: PeerPolicyConfig) -> Self {
Self {
p2p_enabled: Some(value.p2p_enabled),
relay_peer_rpc: Some(value.relay_peer_rpc),
relay_data: Some(value.relay_data),
latency_first: Some(value.latency_first),
encryption_required: Some(value.encryption_required),
}
value.downgrade()
}
}
+4 -4
View File
@@ -6,7 +6,7 @@
use anyhow::Context as _;
use cidr::Ipv6Cidr;
use easytier_proto::common::{FlagsInConfig, PeerFeatureFlag, SecureModeConfig, StunInfo};
use easytier_proto::common::{Flags, PeerFeatureFlag, SecureModeConfig, StunInfo};
use serde::{Deserialize, Serialize};
use crate::proto::acl::{Acl, AclV1, Action, Chain, ChainType, GroupInfo, Protocol, Rule};
@@ -236,7 +236,7 @@ pub struct PeerRuntimeSnapshot {
pub runtime: PeerRuntimeConfig,
pub easytier_version: String,
pub avoid_relay_data_preference: bool,
pub flags: FlagsInConfig,
pub flags: Flags,
pub pinned_peers: Vec<(url::Url, Option<String>)>,
pub peer_group_memberships: Vec<PeerGroupIdentity>,
pub acl_group_declarations: Vec<PeerGroupIdentity>,
@@ -246,7 +246,7 @@ pub struct PeerRuntimeSnapshot {
}
impl PeerRuntimeSnapshot {
pub fn new(runtime: PeerRuntimeConfig, flags: FlagsInConfig) -> Self {
pub fn new(runtime: PeerRuntimeConfig, flags: Flags) -> Self {
let avoid_relay_data_preference = runtime.feature_flags.avoid_relay_data;
Self {
runtime,
@@ -274,7 +274,7 @@ impl Default for PeerRuntimeSnapshot {
secure_mode: None,
host_routing: HostRoutingPolicy::default(),
},
FlagsInConfig::default(),
Flags::default(),
)
}
}
+10 -1
View File
@@ -3,7 +3,7 @@
use std::{collections::BTreeSet, sync::Arc};
use arc_swap::ArcSwap;
use cidr::Ipv4Cidr;
use cidr::{Ipv4Cidr, Ipv6Cidr};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
@@ -16,6 +16,12 @@ use super::{
pub struct CoreRuntimeConfig {
pub acl: AclRuleConfig,
pub dhcp_ipv4: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dhcp_ipv4_cidr: Option<Ipv4Cidr>,
#[serde(default)]
pub dhcp_ipv6: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dhcp_ipv6_cidr: Option<Ipv6Cidr>,
pub gateway: GatewayRuntimeConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub manual_routes: Option<BTreeSet<Ipv4Cidr>>,
@@ -30,6 +36,9 @@ impl Default for CoreRuntimeConfig {
Self {
acl: AclRuleConfig::default(),
dhcp_ipv4: false,
dhcp_ipv4_cidr: None,
dhcp_ipv6: false,
dhcp_ipv6_cidr: None,
gateway: GatewayRuntimeConfig::default(),
manual_routes: None,
proxy: ProxyRuntimeConfig::default(),
+273 -236
View File
@@ -6,172 +6,24 @@ use std::{
sync::{Arc, Mutex},
};
use super::normalize_secure_mode_config;
use super::{DhcpConfig, normalize_secure_mode_config};
pub use super::{EncryptionAlgorithm, gateway::PortForwardConfig};
use anyhow::Context;
#[cfg(feature = "rich-config-errors")]
use ariadne::{CharSet, Config as AriadneConfig, IndexType, Label, Report, ReportKind, Source};
use optionize::Optionizable;
use serde::{Deserialize, Serialize};
use crate::proto::{
acl::Acl,
common::{CompressionAlgoPb, SecureModeConfig},
};
use crate::proto::{acl::Acl, common::SecureModeConfig};
pub const DEFAULT_ET_DNS_ZONE: &str = "et.net.";
pub type Flags = crate::proto::common::FlagsInConfig;
pub use crate::proto::common::{Flags, FlagsPatch};
pub(crate) fn default_instance_name() -> String {
"default".to_owned()
}
pub fn gen_default_flags() -> Flags {
#[allow(deprecated)]
Flags {
default_protocol: "tcp".to_string(),
dev_name: "".to_string(),
enable_encryption: true,
enable_ipv6: true,
mtu: 1380,
latency_first: false,
enable_exit_node: false,
proxy_forward_by_system: false,
no_tun: false,
use_smoltcp: false,
relay_network_whitelist: "*".to_string(),
disable_p2p: false,
p2p_only: false,
lazy_p2p: false,
relay_all_peer_rpc: false,
disable_tcp_hole_punching: false,
disable_udp_hole_punching: false,
multi_thread: true,
data_compress_algo: CompressionAlgoPb::None.into(),
bind_device: true,
enable_kcp_proxy: false,
disable_kcp_input: false,
disable_relay_kcp: false,
enable_relay_foreign_network_kcp: false,
accept_dns: false,
private_mode: false,
enable_quic_proxy: false,
disable_quic_input: false,
disable_relay_quic: false,
enable_relay_foreign_network_quic: false,
foreign_relay_bps_limit: u64::MAX,
multi_thread_count: 2,
encryption_algorithm: EncryptionAlgorithm::default().to_string(),
disable_sym_hole_punching: false,
tld_dns_zone: DEFAULT_ET_DNS_ZONE.to_string(),
quic_listen_port: u32::MAX,
need_p2p: false,
instance_recv_bps_limit: u64::MAX,
disable_upnp: false,
disable_relay_data: false,
prefer_peer_relay: false,
enable_udp_broadcast_relay: false,
socket_mark: None,
}
}
#[cfg(feature = "config-write")]
macro_rules! define_flags_diff {
(
fields: [$($field:ident),* $(,)?],
u64s: [$($u64_field:ident),* $(,)?],
enums: [$($enum_field:ident),* $(,)?]
) => {
#[allow(deprecated)]
fn flags_diff_from_default(flags: &Flags) -> serde_json::Map<String, serde_json::Value> {
let defaults = gen_default_flags();
let mut changed = serde_json::Map::new();
$(
if flags.$field != defaults.$field {
changed.insert(
stringify!($field).to_owned(),
serde_json::to_value(&flags.$field)
.expect("FlagsInConfig field should serialize to JSON"),
);
}
)*
$(
if flags.$u64_field != defaults.$u64_field {
changed.insert(
stringify!($u64_field).to_owned(),
serde_json::json!(flags.$u64_field.to_string()),
);
}
)*
$(
if flags.$enum_field != defaults.$enum_field {
let value = CompressionAlgoPb::try_from(flags.$enum_field)
.map(|value| serde_json::to_value(value).expect("enum should serialize"))
.unwrap_or_else(|_| serde_json::json!(flags.$enum_field));
changed.insert(stringify!($enum_field).to_owned(), value);
}
)*
changed
}
#[cfg(all(test, feature = "config-write"))]
const FLAGS_DIFF_FIELDS: &[&str] = &[
$(stringify!($field),)*
$(stringify!($u64_field),)*
$(stringify!($enum_field),)*
];
};
}
#[cfg(feature = "config-write")]
define_flags_diff! {
fields: [
default_protocol,
dev_name,
enable_encryption,
enable_ipv6,
mtu,
latency_first,
enable_exit_node,
no_tun,
use_smoltcp,
relay_network_whitelist,
disable_p2p,
relay_all_peer_rpc,
disable_udp_hole_punching,
multi_thread,
bind_device,
enable_kcp_proxy,
disable_kcp_input,
disable_relay_kcp,
proxy_forward_by_system,
accept_dns,
private_mode,
enable_quic_proxy,
disable_quic_input,
disable_relay_quic,
quic_listen_port,
multi_thread_count,
enable_relay_foreign_network_kcp,
enable_relay_foreign_network_quic,
encryption_algorithm,
disable_sym_hole_punching,
tld_dns_zone,
p2p_only,
disable_tcp_hole_punching,
lazy_p2p,
need_p2p,
disable_upnp,
disable_relay_data,
prefer_peer_relay,
enable_udp_broadcast_relay,
socket_mark,
],
u64s: [foreign_relay_bps_limit, instance_recv_bps_limit],
enums: [data_compress_algo]
}
#[auto_impl::auto_impl(Box, &)]
pub trait ConfigLoader: Send + Sync {
fn get_id(&self) -> uuid::Uuid;
@@ -201,8 +53,27 @@ pub trait ConfigLoader: Send + Sync {
fn get_ipv6_public_addr_prefix(&self) -> Option<cidr::Ipv6Cidr>;
fn set_ipv6_public_addr_prefix(&self, prefix: Option<cidr::Ipv6Cidr>);
fn get_dhcp(&self) -> bool;
fn set_dhcp(&self, dhcp: bool);
fn get_dhcp_config(&self) -> DhcpConfig;
fn set_dhcp_config(&self, dhcp: DhcpConfig);
fn get_dhcp(&self) -> bool {
self.get_dhcp_config().enabled()
}
fn set_dhcp(&self, dhcp: bool) {
self.set_dhcp_config(DhcpConfig::Enabled(dhcp));
}
fn get_dhcpv6_config(&self) -> DhcpConfig<cidr::Ipv6Cidr>;
fn set_dhcpv6_config(&self, dhcp: DhcpConfig<cidr::Ipv6Cidr>);
fn get_dhcpv6(&self) -> bool {
self.get_dhcpv6_config().enabled()
}
fn set_dhcpv6(&self, dhcp: bool) {
self.set_dhcpv6_config(DhcpConfig::Enabled(dhcp));
}
fn add_proxy_cidr(
&self,
@@ -231,7 +102,12 @@ pub trait ConfigLoader: Send + Sync {
fn set_vpn_portal_config(&self, config: VpnPortalConfig);
fn get_flags(&self) -> Flags;
/// Replace all flags with explicitly supplied values.
fn set_flags(&self, flags: Flags);
/// The user-supplied values, before application defaults are resolved.
fn get_flags_patch(&self) -> FlagsPatch;
/// Apply only supplied fields, preserving absence and explicit defaults.
fn patch_flags(&self, flags: FlagsPatch);
fn get_exit_nodes(&self) -> Vec<IpAddr>;
fn set_exit_nodes(&self, nodes: Vec<IpAddr>);
@@ -486,19 +362,30 @@ fn default_true() -> bool {
true
}
#[optionize::optionized]
#[cfg_attr(any(feature = "web-client", feature = "browser-config"), optionize(object = easytier_proto::api::manage::ManagedCredentialConfig))]
#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ManagedCredentialConfig {
#[optionize(flatten)]
pub credential_id: String,
#[optionize(flatten)]
pub credential_secret: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[optionize(flatten)]
pub groups: Vec<String>,
#[serde(default)]
#[optionize(flatten)]
pub allow_relay: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[optionize(flatten)]
pub allowed_proxy_cidrs: Vec<String>,
#[optionize(flatten)]
pub expiry_unix: i64,
#[serde(default = "default_true")]
// The mapped field is `Option<bool>` (the protocol spells it `optional bool`),
// so this document default does not describe it.
#[optionize(attrs(.., -serde), default = |_| default_true())]
pub reusable: bool,
}
@@ -529,7 +416,8 @@ struct Config {
ipv6_public_addr_provider: Option<bool>,
ipv6_public_addr_auto: Option<bool>,
ipv6_public_addr_prefix: Option<String>,
dhcp: Option<bool>,
dhcp: Option<DhcpConfig>,
dhcpv6: Option<DhcpConfig<cidr::Ipv6Cidr>>,
network_identity: Option<NetworkIdentity>,
listeners: Option<Vec<url::Url>>,
mapped_listeners: Option<Vec<url::Url>>,
@@ -548,10 +436,8 @@ struct Config {
secure_mode: Option<SecureModeConfig>,
flags: Option<serde_json::Map<String, serde_json::Value>>,
#[serde(skip)]
flags_struct: Option<Flags>,
#[serde(default)]
flags: FlagsPatch,
acl: Option<Acl>,
@@ -631,7 +517,6 @@ impl TomlConfig {
fn config_for_dump(&self) -> Config {
let mut config = self.config.lock().unwrap().clone();
Self::normalize_config_source(&mut config);
config.flags = Some(flags_diff_from_default(&self.get_flags()));
config
}
@@ -705,10 +590,6 @@ impl TomlConfig {
}
fn new_from_config(mut config: Config) -> Result<Self, anyhow::Error> {
config.flags_struct = Some(
Self::gen_flags(config.flags.clone().unwrap_or_default())
.context("failed to parse flags")?,
);
config.secure_mode = config
.secure_mode
.take()
@@ -745,17 +626,6 @@ impl TomlConfig {
Ok(config)
}
fn gen_flags(
flags_hashmap: serde_json::Map<String, serde_json::Value>,
) -> serde_json::Result<Flags> {
let mut merged_hashmap = match serde_json::to_value(gen_default_flags()) {
Ok(serde_json::Value::Object(map)) => map,
_ => serde_json::Map::new(),
};
merged_hashmap.extend(flags_hashmap);
serde_json::from_value(serde_json::Value::Object(merged_hashmap))
}
}
#[cfg(feature = "web-client")]
@@ -875,14 +745,22 @@ impl ConfigLoader for TomlConfig {
prefix.map(|prefix| prefix.to_string());
}
fn get_dhcp(&self) -> bool {
fn get_dhcp_config(&self) -> DhcpConfig {
self.config.lock().unwrap().dhcp.unwrap_or_default()
}
fn set_dhcp(&self, dhcp: bool) {
fn set_dhcp_config(&self, dhcp: DhcpConfig) {
self.config.lock().unwrap().dhcp = Some(dhcp);
}
fn get_dhcpv6_config(&self) -> DhcpConfig<cidr::Ipv6Cidr> {
self.config.lock().unwrap().dhcpv6.unwrap_or_default()
}
fn set_dhcpv6_config(&self, dhcp: DhcpConfig<cidr::Ipv6Cidr>) {
self.config.lock().unwrap().dhcpv6 = Some(dhcp);
}
fn add_proxy_cidr(
&self,
cidr: cidr::Ipv4Cidr,
@@ -1019,16 +897,27 @@ impl ConfigLoader for TomlConfig {
}
fn get_flags(&self) -> Flags {
self.config
.lock()
.unwrap()
.flags_struct
.clone()
.unwrap_or_default()
Flags::resolve(self.get_flags_patch())
}
fn set_flags(&self, flags: Flags) {
self.config.lock().unwrap().flags_struct = Some(flags);
self.config.lock().unwrap().flags = flags.downgrade();
}
fn get_flags_patch(&self) -> FlagsPatch {
self.config.lock().unwrap().flags.clone()
}
fn patch_flags(&self, flags: FlagsPatch) {
// Protobuf merge: an update overwrites exactly the fields it carries,
// and presence is the encoding, so a flag explicitly set to its default
// value is applied like any other. Optionize's merge cannot express that
// here, because the one flattened nullable field would have to assign
// unconditionally.
let mut config = self.config.lock().unwrap();
let update = prost::Message::encode_to_vec(&flags);
prost::Message::merge(&mut config.flags, update.as_slice())
.expect("decoding the bytes just encoded cannot fail");
}
fn get_exit_nodes(&self) -> Vec<IpAddr> {
@@ -1205,19 +1094,113 @@ mod tests {
use super::*;
#[test]
fn parse_error_preserves_source_and_location() {
let error =
TomlConfig::new_from_str_with_source("fixture.toml", "dhcp = \"yes\"").unwrap_err();
let display = error.to_string();
fn dhcp_accepts_booleans_and_ipv4_subnets() {
assert!(!TomlConfig::default().get_dhcp());
for (value, expected) in [
("false", DhcpConfig::Enabled(false)),
("true", DhcpConfig::Enabled(true)),
(
"'192.168.100.0/24'",
DhcpConfig::Cidr("192.168.100.0/24".parse().unwrap()),
),
(
"'192.168.100.0/30'",
DhcpConfig::Cidr("192.168.100.0/30".parse().unwrap()),
),
(
"'0.0.0.0/0'",
DhcpConfig::Cidr("0.0.0.0/0".parse().unwrap()),
),
] {
let config = TomlConfig::new_from_str(&format!("dhcp = {value}")).unwrap();
assert_eq!(config.get_dhcp_config(), expected);
assert_eq!(config.get_dhcp(), expected.enabled());
#[cfg(feature = "config-write")]
assert_eq!(
TomlConfig::new_from_str(&config.dump())
.unwrap()
.get_dhcp_config(),
expected
);
config.set_dhcp(false);
assert_eq!(config.get_dhcp_config(), DhcpConfig::Enabled(false));
}
}
assert!(display.contains("fixture.toml"));
assert!(display.contains("dhcp = \"yes\""));
assert!(display.contains("invalid type: string"));
assert!(
error
.chain()
.any(|cause| cause.downcast_ref::<toml::de::Error>().is_some())
);
#[test]
fn dhcpv6_config_round_trip_and_validation() {
assert!(!TomlConfig::default().get_dhcpv6());
for value in [
"true",
"false",
"'fd12:3456::/64'",
"'fd12::/126'",
"'2001:db8::/48'",
] {
let config =
TomlConfig::new_from_str(&format!("dhcp = true\ndhcpv6 = {value}")).unwrap();
let exported = config.dump();
let restored = TomlConfig::new_from_str(&exported).unwrap();
assert_eq!(restored.get_dhcpv6_config(), config.get_dhcpv6_config());
assert!(restored.get_dhcp());
assert!(exported.contains("dhcp = true"));
}
for value in [
"yes",
"10.0.0.0/24",
"fd12::1/64",
"fd12::/127",
"fd12::/128",
"::/0",
"::/64",
"fe80::/64",
"ff00::/8",
] {
assert!(
TomlConfig::new_from_str(&format!("dhcpv6 = '{value}'")).is_err(),
"accepted {value}"
);
}
}
#[test]
fn dhcp_rejects_invalid_or_unusable_subnets() {
for value in [
"'yes'",
"'true'",
"'::/64'",
"'192.168.100.0/31'",
"'192.168.100.1/32'",
"'192.168.100.1/24'",
"'192.168.100.0/33'",
"'192.168.100.1'",
"123",
"{}",
"[]",
] {
assert!(
TomlConfig::new_from_str(&format!("dhcp = {value}")).is_err(),
"accepted {value}"
);
}
}
#[test]
fn parse_error_preserves_source_and_location() {
for field in ["dhcp", "dhcpv6"] {
let input = format!("{field} = \"yes\"");
let error = TomlConfig::new_from_str_with_source("fixture.toml", &input).unwrap_err();
let display = error.to_string();
assert!(display.contains("fixture.toml"));
assert!(display.contains(&input));
assert!(display.contains("invalid DHCP CIDR"));
assert!(
error
.chain()
.any(|cause| cause.downcast_ref::<toml::de::Error>().is_some())
);
}
}
#[cfg(feature = "config-write")]
@@ -1406,37 +1389,9 @@ source = "web"
#[cfg(test)]
mod compatibility_tests {
use super::*;
use crate::proto::common::CompressionAlgoPb;
use base64::{Engine as _, prelude::BASE64_STANDARD};
#[cfg(feature = "config-write")]
#[test]
fn flags_diff_covers_every_protobuf_field() {
use prost::Message as _;
let descriptor_set =
prost_types::FileDescriptorSet::decode(crate::proto::DESCRIPTOR_POOL_BYTES).unwrap();
let proto_fields = descriptor_set
.file
.iter()
.find(|file| file.package.as_deref() == Some("common"))
.and_then(|file| {
file.message_type
.iter()
.find(|message| message.name.as_deref() == Some("FlagsInConfig"))
})
.unwrap()
.field
.iter()
.map(|field| field.name.as_deref().unwrap())
.collect::<std::collections::BTreeSet<_>>();
let diff_fields = FLAGS_DIFF_FIELDS
.iter()
.copied()
.collect::<std::collections::BTreeSet<_>>();
assert_eq!(diff_fields, proto_fields);
}
#[test]
fn socket_mark_config_file_roundtrip_none_some_and_zero() {
// Omitting the flag leaves socket_mark unset (None) -> SO_MARK untouched.
@@ -1486,13 +1441,91 @@ socket_mark = 66
..cfg.get_flags()
});
assert_eq!(cfg.get_flags().socket_mark, None);
#[cfg(feature = "config-write")]
assert_eq!(
TomlConfigLoader::new_from_str(&cfg.dump())
.unwrap()
.get_flags()
.socket_mark,
None
);
}
#[cfg(feature = "config-write")]
#[test]
fn flags_presence_survives_parse_patch_and_dump() {
for (input, expected) in [
("", None),
("enable_encryption = true", Some(true)),
("enable_encryption = false", Some(false)),
] {
let config = TomlConfig::new_from_str(&format!("[flags]\n{input}")).unwrap();
assert_eq!(config.get_flags_patch().enable_encryption, expected);
config.patch_flags(FlagsPatch {
latency_first: Some(false),
..Default::default()
});
let restored = TomlConfig::new_from_str(&config.dump()).unwrap();
assert_eq!(restored.get_flags_patch().enable_encryption, expected);
assert_eq!(restored.get_flags_patch().latency_first, Some(false));
assert_eq!(restored.get_flags_patch().mtu, None);
assert_eq!(
restored.get_flags().enable_encryption,
expected.unwrap_or(true)
);
}
}
#[test]
fn flags_accept_protobuf_aliases_and_numbers_without_default_collisions() {
let config = TomlConfig::new_from_str(
r#"[flags]
enableEncryption = false
mtu = "1420"
foreignRelayBpsLimit = "18446744073709551615"
dataCompressAlgo = "Zstd"
socketMark = "0"
"#,
)
.unwrap();
let flags = config.get_flags();
assert!(!flags.enable_encryption);
assert_eq!(flags.mtu, 1420);
assert_eq!(flags.foreign_relay_bps_limit, u64::MAX);
assert_eq!(flags.data_compress_algo, CompressionAlgoPb::Zstd as i32);
assert_eq!(flags.socket_mark, Some(0));
for fields in [
"enableEncryption = true\nenable_encryption = false",
"mtu = -1",
"mtu = 4294967296",
"unknown_flag = true",
"data_compress_algo = 99",
] {
assert!(
TomlConfig::new_from_str(&format!("[flags]\n{fields}")).is_err(),
"{fields}"
);
}
}
#[cfg(feature = "config-write")]
#[test]
fn dump_omits_all_default_flags() {
let config = TomlConfigLoader::default();
let dumped = config.dump();
let document: toml::Table = toml::from_str(&dumped).unwrap();
assert!(document["flags"].as_table().unwrap().is_empty());
assert_eq!(
TomlConfigLoader::new_from_str(&dumped).unwrap().get_flags(),
Flags::resolve(FlagsPatch::default())
);
}
#[cfg(feature = "config-write")]
#[test]
fn dump_preserves_flags_that_differ_from_easytier_defaults() {
let cfg = TomlConfigLoader::default();
let mut flags = gen_default_flags();
let mut flags = Flags::resolve(FlagsPatch::default());
flags.dev_name = "et_test".to_string();
flags.enable_quic_proxy = true;
flags.disable_tcp_hole_punching = true;
@@ -1973,13 +2006,16 @@ mod diagnostic_compatibility_tests {
#[test]
fn non_ascii_before_typed_error_keeps_byte_location() {
let error = TomlConfig::new_from_str("hostname = \"节点\"\ndhcp = \"yes\"")
.unwrap_err()
.to_string();
for field in ["dhcp", "dhcpv6"] {
let invalid_field = format!("{field} = \"yes\"");
let error = TomlConfig::new_from_str(&format!("hostname = \"节点\"\n{invalid_field}"))
.unwrap_err()
.to_string();
assert!(error.contains("dhcp = \"yes\""));
assert!(error.contains('^'));
assert!(error.contains("invalid type: string"));
assert!(error.contains(&invalid_field));
assert!(error.contains('^'));
assert!(error.contains("invalid DHCP CIDR"));
}
}
#[cfg(feature = "rich-config-errors")]
@@ -1996,7 +2032,7 @@ mod diagnostic_compatibility_tests {
}
#[test]
fn flags_conversion_error_keeps_source_and_cause_chain() {
fn flags_parse_error_keeps_source_span_and_cause_chain() {
let error = TomlConfig::new_from_str_with_source(
"flags-fixture.toml",
"[flags]\nsocket_mark = \"bad\"",
@@ -2005,12 +2041,13 @@ mod diagnostic_compatibility_tests {
let display = error.to_string();
assert!(display.contains("flags-fixture.toml"));
assert!(display.contains("failed to load config"));
assert!(display.contains("failed to parse flags"));
assert!(display.contains("failed to parse config TOML"));
assert!(display.contains("socket_mark = \"bad\""));
assert!(display.contains('^'));
assert!(
error
.chain()
.any(|cause| cause.to_string().contains("failed to parse flags"))
.any(|cause| cause.downcast_ref::<toml::de::Error>().is_some())
);
}
}
@@ -331,6 +331,7 @@ where
};
let layer = self.session_layer(socket.clone());
let hole_punch_observers = layer.hole_punch_observers();
let conn_counter = Arc::new(CoreUdpPunchConnCounter {
layer: Arc::downgrade(&layer),
});
@@ -338,6 +339,7 @@ where
Ok(UdpPunchListener {
socket,
hole_punch_observers,
mapped_addr: resolved.mapped_addr,
conn_counter,
acceptor,
@@ -16,7 +16,7 @@ use crate::{
config::PeerId,
connectivity::stun::StunInfoProvider,
packet::{HOLE_PUNCH_PACKET_BODY_LEN, new_hole_punch_packet},
socket::udp::{UdpBindOptions, VirtualUdpSocketFactory},
socket::udp::{UdpBindOptions, VirtualUdpSocket},
};
use super::{
@@ -62,17 +62,29 @@ where
tracing::info!(?dst_peer_id, "start hole punching");
let tid = rand::random();
let udp_array = UdpSocketArray::new_with_context(1, runtime.clone(), runtime.socket_context());
let resp = signaling
.select_punch_listener(
dst_peer_id,
SelectPunchListener {
force_new: false,
prefer_port_mapping: true,
transaction_id: Some(tid),
},
)
.await?;
if resp
.transaction_id
.is_some_and(|registered| registered != tid)
{
return Err(UdpHolePunchSignalError::RemoteRejected(
"listener registered a different hole-punch transaction".into(),
)
.into());
}
tracing::debug!(
source_observation_registered = resp.transaction_id == Some(tid),
"selected hole-punch listener"
);
let remote_mapped_addr = resp.listener_mapped_addr;
let local_socket = UdpHolePunchRuntime::bind_udp(
@@ -96,14 +108,12 @@ where
"hole punch got remote listener"
);
udp_array.add_new_socket(local_socket).await?;
udp_array.add_intreast_tid(tid);
let punch_packet = new_hole_punch_packet(tid, HOLE_PUNCH_PACKET_BODY_LEN).into_bytes();
send_from_local(&udp_array, &punch_packet, remote_mapped_addr).await?;
send_from_local(local_socket.as_ref(), &punch_packet, remote_mapped_addr).await?;
let signaling_for_task = signaling.clone();
let punch_task = AbortOnDropHandle::new(tokio::spawn(async move {
let _punch_task = AbortOnDropHandle::new(tokio::spawn(async move {
if let Err(e) = signaling_for_task
.send_punch_packet_cone(
dst_peer_id,
@@ -122,33 +132,27 @@ where
}
}));
let mut finish_time: Option<Instant> = None;
while finish_time.is_none() || finish_time.as_ref().unwrap().elapsed().as_millis() < 1000 {
crate::foundation::time::sleep(std::time::Duration::from_millis(200)).await;
if finish_time.is_none() && punch_task.is_finished() {
finish_time = Some(Instant::now());
}
let Some(socket) = udp_array.try_fetch_punched_socket(tid) else {
tracing::debug!("no punched socket found, send some more hole punch packets");
send_from_local(&udp_array, &punch_packet, remote_mapped_addr).await?;
continue;
};
tracing::debug!(?socket, ?tid, "punched socket found, try connect with it");
for _ in 0..2 {
match runtime
.connect_with_socket(socket.socket.clone(), remote_mapped_addr)
.await
{
Ok(socket) => {
tracing::info!(?socket, "hole punched");
return Ok(Some(socket));
// The session layer owns receive while connecting. A reachable listener can
// reply to SYN's actual source even when the STUN candidate differs.
for _ in 0..2 {
let connect = runtime.connect_with_socket(local_socket.clone(), remote_mapped_addr);
tokio::pin!(connect);
loop {
tokio::select! {
result = &mut connect => {
match result {
Ok(socket) => {
tracing::info!(?socket, "hole punched");
return Ok(Some(socket));
}
Err(e) => {
tracing::error!(?e, "failed to connect with socket");
break;
}
}
}
Err(e) => {
tracing::error!(?e, "failed to connect with socket");
_ = crate::foundation::time::sleep(std::time::Duration::from_millis(200)) => {
send_from_local(local_socket.as_ref(), &punch_packet, remote_mapped_addr).await?;
}
}
}
@@ -157,17 +161,20 @@ where
Ok(None)
}
async fn send_from_local<R>(
udp_array: &UdpSocketArray<R>,
async fn send_from_local<S>(
socket: &S,
punch_packet: &[u8],
remote_mapped_addr: SocketAddr,
) -> UdpHolePunchClientResult<()>
where
R: VirtualUdpSocketFactory,
S: VirtualUdpSocket,
{
udp_array
.send_with_all(punch_packet, remote_mapped_addr)
.await?;
for _ in 0..3 {
socket
.send_to(punch_packet, remote_mapped_addr)
.await
.map_err(anyhow::Error::from)?;
}
Ok(())
}
@@ -353,6 +360,7 @@ where
SelectPunchListener {
force_new: false,
prefer_port_mapping: true,
transaction_id: None,
},
)
.await?;
@@ -614,6 +622,10 @@ mod tests {
struct MockSocket {
local_addr: SocketAddr,
incoming: std::sync::Mutex<std::collections::VecDeque<(Vec<u8>, SocketAddr)>>,
incoming_notify: tokio::sync::Notify,
sent: std::sync::Mutex<Vec<(Vec<u8>, SocketAddr)>>,
drop_syn_packets: AtomicUsize,
}
#[async_trait]
@@ -622,12 +634,35 @@ mod tests {
Ok(self.local_addr)
}
async fn send_to(&self, _data: &[u8], _addr: SocketAddr) -> io::Result<usize> {
Ok(0)
async fn send_to(&self, data: &[u8], addr: SocketAddr) -> io::Result<usize> {
self.sent.lock().unwrap().push((data.to_vec(), addr));
if data.len() == 16 && data[4] == crate::packet::UdpPacketType::Syn as u8 {
if self.drop_syn_packets.load(Ordering::Relaxed) != 0 {
self.drop_syn_packets.fetch_sub(1, Ordering::Relaxed);
return Ok(data.len());
}
let conn_id = u32::from_le_bytes(data[..4].try_into().unwrap());
let magic = u64::from_le_bytes(data[8..].try_into().unwrap());
self.incoming.lock().unwrap().push_back((
crate::socket::udp::new_sack_packet(conn_id, magic)
.into_bytes()
.to_vec(),
addr,
));
self.incoming_notify.notify_one();
}
Ok(data.len())
}
async fn recv_from(&self, _buf: &mut [u8]) -> io::Result<(usize, SocketAddr)> {
std::future::pending().await
async fn recv_from(&self, buf: &mut [u8]) -> io::Result<(usize, SocketAddr)> {
loop {
if let Some((data, addr)) = self.incoming.lock().unwrap().pop_front() {
let len = data.len().min(buf.len());
buf[..len].copy_from_slice(&data[..len]);
return Ok((len, addr));
}
self.incoming_notify.notified().await;
}
}
}
@@ -635,6 +670,8 @@ mod tests {
bind_count: AtomicUsize,
resolve_count: AtomicUsize,
bind_options: tokio::sync::Mutex<Vec<UdpBindOptions>>,
sockets: std::sync::Mutex<Vec<std::sync::Weak<MockSocket>>>,
drop_syn_packets: usize,
}
impl MockRuntime {
@@ -643,6 +680,8 @@ mod tests {
bind_count: AtomicUsize::new(0),
resolve_count: AtomicUsize::new(0),
bind_options: tokio::sync::Mutex::new(Vec::new()),
sockets: std::sync::Mutex::new(Vec::new()),
drop_syn_packets: 0,
}
}
}
@@ -654,18 +693,25 @@ mod tests {
async fn bind_udp(&self, options: UdpBindOptions) -> anyhow::Result<Arc<Self::Socket>> {
self.bind_options.lock().await.push(options);
let bind_idx = self.bind_count.fetch_add(1, Ordering::Relaxed);
Ok(Arc::new(MockSocket {
let socket = Arc::new(MockSocket {
local_addr: SocketAddr::from(([127, 0, 0, 1], 10000 + bind_idx as u16)),
}))
incoming: std::sync::Mutex::new(std::collections::VecDeque::new()),
incoming_notify: tokio::sync::Notify::new(),
sent: std::sync::Mutex::new(Vec::new()),
drop_syn_packets: AtomicUsize::new(self.drop_syn_packets),
});
self.sockets.lock().unwrap().push(Arc::downgrade(&socket));
Ok(socket)
}
async fn resolve_udp_public_addr(
&self,
_socket: Arc<Self::Socket>,
socket: Arc<Self::Socket>,
) -> anyhow::Result<super::super::UdpResolvedPublicAddr> {
assert_eq!(socket.local_addr()?.port(), 10000);
self.resolve_count.fetch_add(1, Ordering::Relaxed);
Ok(super::super::UdpResolvedPublicAddr {
mapped_addr: SocketAddr::from(([203, 0, 113, 1], 10000)),
mapped_addr: SocketAddr::from(([203, 0, 113, 1], 40000)),
port_mapping_lease: None,
})
}
@@ -686,10 +732,12 @@ mod tests {
async fn connect_with_socket(
&self,
_socket: Arc<Self::Socket>,
_remote: SocketAddr,
socket: Arc<Self::Socket>,
remote: SocketAddr,
) -> anyhow::Result<UdpPunchSocket> {
unimplemented!("not used by cone client tests")
let layer = Arc::new(crate::socket::udp::UdpSessionLayer::new(socket));
let session = layer.connect(remote).await?;
Ok(UdpPunchSocket::new(session, remote, layer))
}
}
@@ -797,6 +845,8 @@ mod tests {
}
struct RecordingSignaling {
listener_requests: tokio::sync::Mutex<Vec<SelectPunchListener>>,
confirm_transaction_id: bool,
easy_requests: tokio::sync::Mutex<Vec<SendPunchPacketEasySym>>,
hard_requests: tokio::sync::Mutex<Vec<SendPunchPacketHardSym>>,
both_requests: tokio::sync::Mutex<Vec<SendPunchPacketBothEasySym>>,
@@ -804,9 +854,100 @@ mod tests {
next_port_index: u32,
}
#[tokio::test]
async fn cone_connects_without_reverse_hole_punch_and_retries_dropped_syn() {
for (drop_syn_packets, confirm_transaction_id) in
[(0, true), (1, true), (0, false), (1, false)]
{
let mut runtime = MockRuntime::new();
runtime.drop_syn_packets = drop_syn_packets;
let runtime = Arc::new(runtime);
let mut signaling = RecordingSignaling::new(0);
signaling.confirm_transaction_id = confirm_transaction_id;
let signaling = Arc::new(signaling);
let result = tokio::time::timeout(
std::time::Duration::from_secs(1),
punch_cone_to_cone(runtime.clone(), signaling.clone(), 2),
)
.await
.unwrap()
.unwrap()
.unwrap();
assert_eq!(runtime.bind_count.load(Ordering::Relaxed), 1);
assert_eq!(runtime.resolve_count.load(Ordering::Relaxed), 1);
let tid = signaling.listener_requests.lock().await[0]
.transaction_id
.unwrap();
let socket = runtime.sockets.lock().unwrap()[0].upgrade().unwrap();
let sent = socket.sent.lock().unwrap();
let syns = sent
.iter()
.filter(|(packet, _)| packet[4] == crate::packet::UdpPacketType::Syn as u8)
.count();
assert_eq!(syns, drop_syn_packets + 1);
assert!(sent.iter().any(|(packet, _)| {
crate::packet::hole_punch_packet_tid(packet, HOLE_PUNCH_PACKET_BODY_LEN)
== Some(tid)
}));
assert!(
sent.iter()
.all(|(_, addr)| *addr == SocketAddr::from(([127, 0, 0, 1], 30000)))
);
drop(sent);
drop(result);
}
}
#[tokio::test]
async fn cancelling_cone_connect_releases_socket_and_receive_task() {
let mut runtime = MockRuntime::new();
runtime.drop_syn_packets = usize::MAX;
let runtime = Arc::new(runtime);
let task_runtime = runtime.clone();
let task = tokio::spawn(async move {
punch_cone_to_cone(task_runtime, Arc::new(RecordingSignaling::new(0)), 2).await
});
let socket = tokio::time::timeout(std::time::Duration::from_secs(1), async {
loop {
let socket = runtime
.sockets
.lock()
.unwrap()
.first()
.and_then(std::sync::Weak::upgrade);
if let Some(socket) = socket
&& socket
.sent
.lock()
.unwrap()
.iter()
.any(|(packet, _)| packet[4] == crate::packet::UdpPacketType::Syn as u8)
{
break socket;
}
tokio::task::yield_now().await;
}
})
.await
.unwrap();
let weak = Arc::downgrade(&socket);
drop(socket);
task.abort();
assert!(task.await.unwrap_err().is_cancelled());
tokio::time::timeout(std::time::Duration::from_secs(1), async {
while weak.upgrade().is_some() {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
}
impl RecordingSignaling {
fn new(next_port_index: u32) -> Self {
Self {
listener_requests: tokio::sync::Mutex::new(Vec::new()),
confirm_transaction_id: true,
easy_requests: tokio::sync::Mutex::new(Vec::new()),
hard_requests: tokio::sync::Mutex::new(Vec::new()),
both_requests: tokio::sync::Mutex::new(Vec::new()),
@@ -832,10 +973,16 @@ mod tests {
async fn select_punch_listener(
&self,
_dst_peer_id: PeerId,
_request: SelectPunchListener,
request: SelectPunchListener,
) -> Result<super::super::SelectPunchListenerResponse, UdpHolePunchSignalError> {
let transaction_id = self
.confirm_transaction_id
.then_some(request.transaction_id)
.flatten();
self.listener_requests.lock().await.push(request);
Ok(super::super::SelectPunchListenerResponse {
listener_mapped_addr: SocketAddr::from(([127, 0, 0, 1], 30000)),
transaction_id,
})
}
@@ -56,6 +56,7 @@ fn select_listener_request_to_rpc(request: SelectPunchListener) -> SelectPunchLi
SelectPunchListenerRequest {
force_new: request.force_new,
prefer_port_mapping: request.prefer_port_mapping,
transaction_id: request.transaction_id,
}
}
@@ -63,6 +64,7 @@ fn select_listener_request_from_rpc(input: SelectPunchListenerRequest) -> Select
SelectPunchListener {
force_new: input.force_new,
prefer_port_mapping: input.prefer_port_mapping,
transaction_id: input.transaction_id,
}
}
@@ -70,6 +72,7 @@ fn select_listener_response_from_rpc(
response: SelectPunchListenerResponse,
) -> Result<CoreSelectPunchListenerResponse, UdpHolePunchSignalError> {
Ok(CoreSelectPunchListenerResponse {
transaction_id: response.transaction_id,
listener_mapped_addr: SocketAddr::from(
response
.listener_mapped_addr
@@ -83,6 +86,7 @@ fn select_listener_response_to_rpc(
) -> SelectPunchListenerResponse {
SelectPunchListenerResponse {
listener_mapped_addr: Some(response.listener_mapped_addr.into()),
transaction_id: response.transaction_id,
}
}
@@ -553,8 +557,62 @@ where
mod tests {
use std::{future, net::Ipv4Addr, time::Duration};
use prost::Message;
use super::*;
#[derive(Clone, PartialEq, Message)]
struct LegacySelectPunchListenerRequest {
#[prost(bool, tag = "1")]
force_new: bool,
#[prost(bool, tag = "2")]
prefer_port_mapping: bool,
}
#[derive(Clone, PartialEq, Message)]
struct LegacySelectPunchListenerResponse {
#[prost(message, optional, tag = "1")]
listener_mapped_addr: Option<crate::proto::common::SocketAddr>,
}
#[test]
fn listener_registration_fields_are_compatible_with_legacy_wire_messages() {
let request = select_listener_request_to_rpc(SelectPunchListener {
force_new: true,
prefer_port_mapping: false,
transaction_id: Some(0),
});
assert_eq!(
SelectPunchListenerRequest::decode(request.encode_to_vec().as_slice())
.unwrap()
.transaction_id,
Some(0)
);
let legacy_request =
LegacySelectPunchListenerRequest::decode(request.encode_to_vec().as_slice()).unwrap();
assert!(legacy_request.force_new);
let request =
SelectPunchListenerRequest::decode(legacy_request.encode_to_vec().as_slice()).unwrap();
assert_eq!(
select_listener_request_from_rpc(request).transaction_id,
None
);
let mapped_addr = "198.51.100.1:31001".parse().unwrap();
let response = select_listener_response_to_rpc(CoreSelectPunchListenerResponse {
listener_mapped_addr: mapped_addr,
transaction_id: Some(0),
});
let legacy_response =
LegacySelectPunchListenerResponse::decode(response.encode_to_vec().as_slice()).unwrap();
let response =
SelectPunchListenerResponse::decode(legacy_response.encode_to_vec().as_slice())
.unwrap();
let response = select_listener_response_from_rpc(response).unwrap();
assert_eq!(response.listener_mapped_addr, mapped_addr);
assert_eq!(response.transaction_id, None);
}
#[test]
fn outbound_rpc_controllers_preserve_timeouts() {
assert_eq!(cone_controller().timeout_ms, 4000);
@@ -568,14 +626,17 @@ mod tests {
let domain_request = SelectPunchListener {
force_new: true,
prefer_port_mapping: false,
transaction_id: Some(0),
};
let rpc_request = select_listener_request_to_rpc(domain_request.clone());
assert!(rpc_request.force_new);
assert!(!rpc_request.prefer_port_mapping);
assert_eq!(rpc_request.transaction_id, Some(0));
assert_eq!(
select_listener_request_from_rpc(SelectPunchListenerRequest {
force_new: true,
prefer_port_mapping: false,
transaction_id: Some(0),
}),
domain_request
);
@@ -583,6 +644,7 @@ mod tests {
let mapped_addr: SocketAddr = "198.51.100.1:31001".parse().unwrap();
let core_response = CoreSelectPunchListenerResponse {
listener_mapped_addr: mapped_addr,
transaction_id: Some(0),
};
let rpc_response = select_listener_response_to_rpc(core_response.clone());
assert_eq!(
@@ -17,7 +17,10 @@ use crate::{
peers::PeerConnectionOrigin,
socket::{
ListenerConnectionCounter, SocketContext,
udp::{UdpBindOptions, UdpSession, VirtualUdpSocket, VirtualUdpSocketFactory},
udp::{
UdpBindOptions, UdpHolePunchObserverRegistry, UdpSession, VirtualUdpSocket,
VirtualUdpSocketFactory,
},
},
tunnel::Tunnel,
};
@@ -73,6 +76,7 @@ fn udp_url(addr: SocketAddr) -> url::Url {
pub struct UdpPunchListener<S> {
pub socket: Arc<S>,
pub hole_punch_observers: UdpHolePunchObserverRegistry,
pub mapped_addr: SocketAddr,
pub conn_counter: Arc<dyn ListenerConnectionCounter>,
pub acceptor: Box<dyn UdpPunchAcceptor>,
@@ -88,11 +92,13 @@ pub struct UdpResolvedPublicAddr {
pub struct SelectPunchListener {
pub force_new: bool,
pub prefer_port_mapping: bool,
pub transaction_id: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SelectPunchListenerResponse {
pub listener_mapped_addr: SocketAddr,
pub transaction_id: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -8,7 +8,7 @@ use std::{
};
use anyhow::Context;
use crossbeam::atomic::AtomicCell;
use crossbeam_utils::atomic::AtomicCell;
use quanta::Instant;
use rand::{Rng, seq::SliceRandom as _};
use tokio::{
@@ -35,6 +35,9 @@ use super::{
};
const MAX_K1_FOR_RANDOM_HARD_SYM: u32 = 180;
// Selection precedes local socket/STUN preparation. Match the listener's
// selection lifetime so abandoned preparations expire without per-RPC tasks.
const PREPARED_CONE_PUNCH_TIMEOUT: Duration = Duration::from_secs(30);
pub struct SelectedUdpPunchListener<S> {
/// Kept for listener-selection side effects and tests; production callers
@@ -266,8 +269,16 @@ where
UdpHolePunchSignalError::RemoteRejected("no listener available".into())
})?;
if let Some(transaction_id) = request.transaction_id {
self.common
.prepare_hole_punch(&selected.mapped_addr, transaction_id)
.await
.map_err(Self::anyhow_to_signal_error)?;
}
Ok(SelectPunchListenerResponse {
listener_mapped_addr: selected.mapped_addr,
transaction_id: request.transaction_id,
})
}
@@ -276,17 +287,12 @@ where
request: SendPunchPacketCone,
) -> Result<(), UdpHolePunchSignalError> {
let _admission = self.admit().await?;
let listener = self
let (listener, observer) = self
.common
.find_listener(&request.listener_mapped_addr)
.observe_hole_punch(&request.listener_mapped_addr, request.transaction_id)
.await
.ok_or_else(|| {
UdpHolePunchSignalError::RemoteRejected(
"send_punch_packet_for_cone failed to find listener".into(),
)
})?;
send_cone_hole_punch_packets(listener, &request)
.map_err(Self::anyhow_to_signal_error)?;
send_cone_hole_punch_packets(listener, &request, observer)
.await
.map_err(Self::anyhow_to_signal_error)
}
@@ -386,6 +392,7 @@ where
let mut index = 0;
while index < listeners.len() {
let listener = &listeners[index];
listener.hole_punch_observers.expire_prepared();
let active = listener.last_active_time.load().elapsed().as_secs() < 40
|| listener.last_select_time.load().elapsed().as_secs() < 30;
if active {
@@ -473,6 +480,36 @@ where
Some(listener.get_socket())
}
async fn prepare_hole_punch(
&self,
addr: &SocketAddr,
transaction_id: u32,
) -> anyhow::Result<()> {
let listeners = self.listeners.lock().await;
let listener = listeners
.iter()
.find(|listener| listener.mapped_addr == *addr && listener.running.load())
.ok_or_else(|| anyhow::anyhow!("failed to find hole-punch listener"))?;
listener
.hole_punch_observers
.prepare(transaction_id, PREPARED_CONE_PUNCH_TIMEOUT)?;
Ok(())
}
async fn observe_hole_punch(
&self,
addr: &SocketAddr,
transaction_id: u32,
) -> anyhow::Result<(Arc<R::Socket>, crate::socket::udp::UdpHolePunchObserver)> {
let listeners = self.listeners.lock().await;
let listener = listeners
.iter()
.find(|listener| listener.mapped_addr == *addr && listener.running.load())
.ok_or_else(|| anyhow::anyhow!("send_punch_packet_for_cone failed to find listener"))?;
let observer = listener.hole_punch_observers.observe(transaction_id)?;
Ok((listener.get_socket(), observer))
}
pub async fn select_listener(
&self,
force_new_listener: bool,
@@ -572,6 +609,7 @@ where
struct UdpPunchListenerRecord<S> {
socket: Arc<S>,
hole_punch_observers: crate::socket::udp::UdpHolePunchObserverRegistry,
tasks: Mutex<JoinSet<()>>,
connection_tasks: Arc<Mutex<JoinSet<()>>>,
running: Arc<AtomicCell<bool>>,
@@ -595,6 +633,7 @@ where
{
let UdpPunchListener {
socket,
hole_punch_observers,
mapped_addr,
conn_counter,
mut acceptor,
@@ -646,6 +685,7 @@ where
Self {
socket,
hole_punch_observers,
tasks: Mutex::new(tasks),
connection_tasks,
running,
@@ -726,6 +766,7 @@ where
pub async fn send_cone_hole_punch_packets<S>(
udp: Arc<S>,
request: &SendPunchPacketCone,
mut observer: crate::socket::udp::UdpHolePunchObserver,
) -> anyhow::Result<()>
where
S: VirtualUdpSocket + 'static,
@@ -738,21 +779,47 @@ where
);
}
for _ in 0..request.packet_batch_count {
tracing::info!(?request, "sending hole punching packet");
let udp = udp.as_ref();
let send_batch = |dest_addr| async move {
for _ in 0..request.packet_count_per_batch {
let udp_packet =
new_hole_punch_packet(request.transaction_id, HOLE_PUNCH_PACKET_BODY_LEN);
if let Err(err) = udp
.send_to(&udp_packet.into_bytes(), request.dest_addr)
.await
{
tracing::error!(?err, "failed to send hole punch packet to dest addr");
if let Err(err) = udp.send_to(&udp_packet.into_bytes(), dest_addr).await {
tracing::error!(
?err,
?dest_addr,
"failed to send hole punch packet to dest addr"
);
}
}
crate::foundation::time::sleep(Duration::from_millis(request.packet_interval_ms as u64))
.await;
};
let mut observed_addr = observer.source_addr();
for _ in 0..request.packet_batch_count {
tracing::info!(?request, "sending hole punching packet");
send_batch(request.dest_addr).await;
if let Some(addr) = observed_addr.filter(|addr| *addr != request.dest_addr) {
send_batch(addr).await;
}
let interval = crate::foundation::time::sleep(Duration::from_millis(
request.packet_interval_ms as u64,
));
tokio::pin!(interval);
if observed_addr.is_none() {
tokio::select! {
_ = &mut interval => {}
addr = observer.wait_for_source_addr() => {
let addr = addr?;
observed_addr = Some(addr);
if addr != request.dest_addr {
send_batch(addr).await;
}
interval.await;
}
}
} else {
interval.await;
}
}
Ok(())
@@ -971,6 +1038,8 @@ mod tests {
local_addr: SocketAddr,
sent: tokio::sync::Mutex<Vec<(Vec<u8>, SocketAddr)>>,
fail_next_send: AtomicUsize,
incoming: StdMutex<VecDeque<(Vec<u8>, SocketAddr)>>,
incoming_notify: tokio::sync::Notify,
}
#[async_trait]
@@ -988,8 +1057,15 @@ mod tests {
Ok(data.len())
}
async fn recv_from(&self, _buf: &mut [u8]) -> io::Result<(usize, SocketAddr)> {
std::future::pending().await
async fn recv_from(&self, buf: &mut [u8]) -> io::Result<(usize, SocketAddr)> {
loop {
if let Some((data, addr)) = self.incoming.lock().unwrap().pop_front() {
let len = data.len().min(buf.len());
buf[..len].copy_from_slice(&data[..len]);
return Ok((len, addr));
}
self.incoming_notify.notified().await;
}
}
}
@@ -1006,6 +1082,7 @@ mod tests {
struct MockAcceptor {
sockets: VecDeque<UdpPunchSocket>,
_layer: Arc<crate::socket::udp::UdpSessionLayer<MockSocket>>,
}
#[async_trait]
@@ -1039,6 +1116,8 @@ mod tests {
local_addr: SocketAddr::from(([127, 0, 0, 1], 0)),
sent: tokio::sync::Mutex::new(Vec::new()),
fail_next_send: AtomicUsize::new(0),
incoming: StdMutex::new(VecDeque::new()),
incoming_notify: tokio::sync::Notify::new(),
}))
}
@@ -1130,16 +1209,22 @@ mod tests {
}
fn listener(port: u16, sockets: Vec<UdpPunchSocket>) -> UdpPunchListener<MockSocket> {
let socket = Arc::new(MockSocket {
local_addr: SocketAddr::from(([127, 0, 0, 1], port)),
sent: tokio::sync::Mutex::new(Vec::new()),
fail_next_send: AtomicUsize::new(0),
incoming: StdMutex::new(VecDeque::new()),
incoming_notify: tokio::sync::Notify::new(),
});
let layer = Arc::new(crate::socket::udp::UdpSessionLayer::new(socket.clone()));
UdpPunchListener {
socket: Arc::new(MockSocket {
local_addr: SocketAddr::from(([127, 0, 0, 1], port)),
sent: tokio::sync::Mutex::new(Vec::new()),
fail_next_send: AtomicUsize::new(0),
}),
socket,
hole_punch_observers: layer.hole_punch_observers(),
mapped_addr: SocketAddr::from(([203, 0, 113, 1], port)),
conn_counter: Arc::new(MockCounter::default()),
acceptor: Box::new(MockAcceptor {
sockets: sockets.into(),
_layer: layer,
}),
port_mapping_lease: None,
}
@@ -1206,6 +1291,8 @@ mod tests {
local_addr: SocketAddr::from(([127, 0, 0, 1], 10001)),
sent: tokio::sync::Mutex::new(Vec::new()),
fail_next_send: AtomicUsize::new(0),
incoming: StdMutex::new(VecDeque::new()),
incoming_notify: tokio::sync::Notify::new(),
});
let remote_addr = SocketAddr::from(([127, 0, 0, 1], 20001));
let session =
@@ -1231,12 +1318,248 @@ mod tests {
assert_eq!(sink.server_tunnels.load(Ordering::Relaxed), 1);
}
#[tokio::test]
async fn selected_listener_observes_first_packet_before_cone_rpc_arrives() {
let runtime = Arc::new(MockRuntime::new(vec![listener(10002, Vec::new())]));
let server = UdpHolePunchServer::new(
runtime,
mock_stun(),
Arc::new(MockSink::default()),
UdpSymPunchLock::default(),
);
server.start().await;
let selected = server
.select_punch_listener(SelectPunchListener {
force_new: false,
prefer_port_mapping: false,
transaction_id: Some(0),
})
.await
.unwrap();
assert_eq!(selected.transaction_id, Some(0));
let socket = server
.common
.find_listener(&selected.listener_mapped_addr)
.await
.unwrap();
let source = SocketAddr::from(([198, 51, 100, 1], 33140));
socket.incoming.lock().unwrap().push_back((
new_hole_punch_packet(0, HOLE_PUNCH_PACKET_BODY_LEN)
.into_bytes()
.to_vec(),
source,
));
socket.incoming_notify.notify_one();
tokio::time::timeout(Duration::from_secs(1), async {
while !socket.incoming.lock().unwrap().is_empty() {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
let dest_addr = SocketAddr::from(([198, 51, 100, 1], 33105));
server
.send_punch_packet_cone(SendPunchPacketCone {
listener_mapped_addr: selected.listener_mapped_addr,
dest_addr,
transaction_id: 0,
packet_count_per_batch: 2,
packet_batch_count: 1,
packet_interval_ms: 0,
})
.await
.unwrap();
let sent = socket.sent.lock().await;
assert_eq!(sent.len(), 4);
assert_eq!(
sent.iter().filter(|(_, addr)| *addr == dest_addr).count(),
2
);
assert_eq!(sent.iter().filter(|(_, addr)| *addr == source).count(), 2);
drop(sent);
server.stop().await;
}
#[tokio::test(start_paused = true)]
async fn cleanup_expires_abandoned_listener_registration() {
let runtime = Arc::new(MockRuntime::new(vec![listener(10002, Vec::new())]));
let server = UdpHolePunchServer::new(
runtime,
mock_stun(),
Arc::new(MockSink::default()),
UdpSymPunchLock::default(),
);
server.start().await;
let selected = server
.select_punch_listener(SelectPunchListener {
force_new: false,
prefer_port_mapping: false,
transaction_id: Some(9),
})
.await
.unwrap();
assert!(
server
.common
.prepare_hole_punch(&selected.listener_mapped_addr, 9)
.await
.is_err()
);
// Let the existing periodic cleanup run as the paused clock advances.
crate::foundation::time::sleep(PREPARED_CONE_PUNCH_TIMEOUT + Duration::from_secs(5)).await;
server
.common
.prepare_hole_punch(&selected.listener_mapped_addr, 9)
.await
.unwrap();
server.stop().await;
}
#[tokio::test]
async fn cone_sender_adds_observed_source_during_last_batch_and_deduplicates() {
let runtime = Arc::new(MockRuntime::new(vec![listener(10002, Vec::new())]));
let server = UdpHolePunchServer::new(
runtime,
mock_stun(),
Arc::new(MockSink::default()),
UdpSymPunchLock::default(),
);
server.start().await;
let selected = server
.select_punch_listener(SelectPunchListener {
force_new: false,
prefer_port_mapping: false,
transaction_id: None,
})
.await
.unwrap();
let socket = server
.common
.find_listener(&selected.listener_mapped_addr)
.await
.unwrap();
let request = SendPunchPacketCone {
listener_mapped_addr: selected.listener_mapped_addr,
dest_addr: SocketAddr::from(([198, 51, 100, 1], 33105)),
transaction_id: 9,
packet_count_per_batch: 2,
packet_batch_count: 1,
packet_interval_ms: 100,
};
for observed_addr in [
SocketAddr::from(([198, 51, 100, 1], 33140)),
request.dest_addr,
] {
socket.sent.lock().await.clear();
let send = server.send_punch_packet_cone(request.clone());
let observe = async {
loop {
if socket.sent.lock().await.len() == 2 {
break;
}
tokio::task::yield_now().await;
}
socket.incoming.lock().unwrap().push_back((
new_hole_punch_packet(request.transaction_id, HOLE_PUNCH_PACKET_BODY_LEN)
.into_bytes()
.to_vec(),
observed_addr,
));
socket.incoming_notify.notify_one();
};
let (result, _) = tokio::time::timeout(Duration::from_secs(2), async {
tokio::join!(send, observe)
})
.await
.unwrap();
result.unwrap();
let sent = socket.sent.lock().await;
let expected_count = if observed_addr == request.dest_addr {
2
} else {
4
};
assert_eq!(sent.len(), expected_count);
assert_eq!(
sent.iter()
.filter(|(_, addr)| *addr == request.dest_addr)
.count(),
2
);
for (packet, _) in sent.iter() {
assert_eq!(
crate::packet::hole_punch_packet_tid(packet, HOLE_PUNCH_PACKET_BODY_LEN),
Some(request.transaction_id)
);
}
if observed_addr != request.dest_addr {
assert_eq!(
sent.iter()
.filter(|(_, addr)| *addr == observed_addr)
.count(),
2
);
}
}
let (_, observer) = server
.common
.observe_hole_punch(&request.listener_mapped_addr, request.transaction_id)
.await
.unwrap();
assert_eq!(observer.source_addr(), None);
drop(observer);
server.stop().await;
}
#[tokio::test]
async fn cancelling_cone_sender_unregisters_observed_source() {
let socket = Arc::new(MockSocket {
local_addr: SocketAddr::from(([127, 0, 0, 1], 10002)),
sent: tokio::sync::Mutex::new(Vec::new()),
fail_next_send: AtomicUsize::new(0),
incoming: StdMutex::new(VecDeque::new()),
incoming_notify: tokio::sync::Notify::new(),
});
let layer = crate::socket::udp::UdpSessionLayer::new(socket.clone());
let request = SendPunchPacketCone {
listener_mapped_addr: SocketAddr::from(([203, 0, 113, 1], 10002)),
dest_addr: SocketAddr::from(([198, 51, 100, 1], 33105)),
transaction_id: 9,
packet_count_per_batch: 2,
packet_batch_count: 5,
packet_interval_ms: 400,
};
let observer = layer
.hole_punch_observers()
.observe(request.transaction_id)
.unwrap();
let send_socket = socket.clone();
let task = tokio::spawn(async move {
send_cone_hole_punch_packets(send_socket, &request, observer).await
});
while socket.sent.lock().await.is_empty() {
tokio::task::yield_now().await;
}
task.abort();
assert!(task.await.unwrap_err().is_cancelled());
assert_eq!(
layer
.hole_punch_observers()
.observe(9)
.unwrap()
.source_addr(),
None
);
}
#[tokio::test]
async fn cone_packet_sender_keeps_old_batch_shape() {
let socket = Arc::new(MockSocket {
local_addr: SocketAddr::from(([127, 0, 0, 1], 10002)),
sent: tokio::sync::Mutex::new(Vec::new()),
fail_next_send: AtomicUsize::new(0),
incoming: StdMutex::new(VecDeque::new()),
incoming_notify: tokio::sync::Notify::new(),
});
let request = SendPunchPacketCone {
listener_mapped_addr: SocketAddr::from(([203, 0, 113, 1], 10002)),
@@ -1247,7 +1570,12 @@ mod tests {
packet_interval_ms: 0,
};
send_cone_hole_punch_packets(socket.clone(), &request)
let layer = crate::socket::udp::UdpSessionLayer::new(socket.clone());
let observer = layer
.hole_punch_observers()
.observe(request.transaction_id)
.unwrap();
send_cone_hole_punch_packets(socket.clone(), &request, observer)
.await
.unwrap();
@@ -1262,6 +1590,8 @@ mod tests {
local_addr: SocketAddr::from(([127, 0, 0, 1], 10004)),
sent: tokio::sync::Mutex::new(Vec::new()),
fail_next_send: AtomicUsize::new(0),
incoming: StdMutex::new(VecDeque::new()),
incoming_notify: tokio::sync::Notify::new(),
});
let request = SendPunchPacketCone {
listener_mapped_addr: SocketAddr::from(([203, 0, 113, 1], 10004)),
@@ -1272,7 +1602,12 @@ mod tests {
packet_interval_ms: 0,
};
let err = send_cone_hole_punch_packets(socket.clone(), &request)
let layer = crate::socket::udp::UdpSessionLayer::new(socket.clone());
let observer = layer
.hole_punch_observers()
.observe(request.transaction_id)
.unwrap();
let err = send_cone_hole_punch_packets(socket.clone(), &request, observer)
.await
.unwrap_err();
@@ -1286,6 +1621,8 @@ mod tests {
local_addr: SocketAddr::from(([127, 0, 0, 1], 10005)),
sent: tokio::sync::Mutex::new(Vec::new()),
fail_next_send: AtomicUsize::new(1),
incoming: StdMutex::new(VecDeque::new()),
incoming_notify: tokio::sync::Notify::new(),
});
let request = SendPunchPacketCone {
listener_mapped_addr: SocketAddr::from(([203, 0, 113, 1], 10005)),
@@ -1296,7 +1633,12 @@ mod tests {
packet_interval_ms: 0,
};
send_cone_hole_punch_packets(socket.clone(), &request)
let layer = crate::socket::udp::UdpSessionLayer::new(socket.clone());
let observer = layer
.hole_punch_observers()
.observe(request.transaction_id)
.unwrap();
send_cone_hole_punch_packets(socket.clone(), &request, observer)
.await
.unwrap();
@@ -1310,6 +1652,8 @@ mod tests {
local_addr: SocketAddr::from(([127, 0, 0, 1], 10003)),
sent: tokio::sync::Mutex::new(Vec::new()),
fail_next_send: AtomicUsize::new(0),
incoming: StdMutex::new(VecDeque::new()),
incoming_notify: tokio::sync::Notify::new(),
});
let next_idx = send_symmetric_hole_punch_packet(
&[10, 11, 12],
@@ -1335,6 +1679,8 @@ mod tests {
local_addr: SocketAddr::from(([127, 0, 0, 1], 10006)),
sent: tokio::sync::Mutex::new(Vec::new()),
fail_next_send: AtomicUsize::new(1),
incoming: StdMutex::new(VecDeque::new()),
incoming_notify: tokio::sync::Notify::new(),
});
let err = send_symmetric_hole_punch_packet(
@@ -187,9 +187,16 @@ where
let root_store = rustls::RootCertStore {
roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(),
};
let tls_config = rustls::ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth();
// A host application can enable another rustls backend through Cargo
// feature unification. Select ring explicitly instead of requiring a
// process-wide provider to have been initialized first (#2607).
let tls_config = rustls::ClientConfig::builder_with_provider(Arc::new(
rustls::crypto::ring::default_provider(),
))
.with_safe_default_protocol_versions()
.context("selecting HTTPS protocol versions failed")?
.with_root_certificates(root_store)
.with_no_client_auth();
let stream = TlsConnector::from(Arc::new(tls_config))
.connect(server_name, socket)
.await
@@ -631,6 +638,44 @@ mod tests {
assert_eq!(options.bind.context.socket_mark, Some(9));
}
#[tokio::test]
async fn https_fetch_reaches_tls_handshake_without_global_provider() {
// Also run with rustls/aws_lc_rs enabled: feature unification must not
// make HTTPS discovery panic before it can send a ClientHello (#2607).
let (client, mut server) = tokio::io::duplex(8192);
let host = Arc::new(HttpTestHost {
stream: Mutex::new(Some(client)),
connects: Mutex::new(Vec::new()),
});
let dns = HttpTestDns {
queries: Mutex::new(Vec::new()),
};
let server_task = tokio::spawn(async move {
let mut record_header = [0; 5];
server.read_exact(&mut record_header).await.unwrap();
assert_eq!(record_header[0], 22, "expected a TLS handshake record");
// Close without replying; discovery should report a handshake
// error rather than panic while selecting a crypto provider.
});
let error = fetch_http_discovery(
host,
&dns,
HttpDiscoveryRequest {
url: "https://discovery.example/lookup".parse().unwrap(),
user_agent: "easytier/test".to_owned(),
network_name: "test-network".to_owned(),
timeout: Duration::from_secs(5),
ip_version: IpVersion::V4,
tcp_bind: TcpBindOptions::default(),
},
)
.await
.expect_err("server closed during the TLS handshake");
assert!(format!("{error:#}").contains("HTTPS handshake failed"));
server_task.await.unwrap();
}
#[test]
fn http_discovery_interprets_redirect_and_body_forms() {
let query = resolve_http_endpoint(HttpDiscoveryResponse {
+8 -7
View File
@@ -1381,13 +1381,14 @@ mod tests {
#[tokio::test]
async fn timeout_budget_reports_the_active_stage() {
let error =
with_timeout_budget("connect", Instant::now(), Duration::from_millis(1), async {
crate::foundation::time::sleep(Duration::from_millis(20)).await;
Ok::<(), anyhow::Error>(())
})
.await
.unwrap_err();
let error = with_timeout_budget(
"connect",
Instant::now(),
Duration::from_millis(1),
std::future::pending::<anyhow::Result<()>>(),
)
.await
.unwrap_err();
assert!(error.to_string().contains("connect timeout after"));
}
+12
View File
@@ -14,3 +14,15 @@ pub mod stats;
pub(crate) mod task;
pub(crate) mod time;
pub(crate) mod token_bucket;
// Each network and peer owns several small maps. Cap their shard arrays to
// avoid multiplying empty-map overhead on public servers, while retaining
// DashMap's smaller default on single-CPU machines.
pub(crate) fn small_map_shard_amount() -> usize {
static SHARDS: std::sync::LazyLock<usize> = std::sync::LazyLock::new(|| {
(std::thread::available_parallelism().map_or(1, usize::from) * 4)
.next_power_of_two()
.min(8)
});
*SHARDS
}
+17 -10
View File
@@ -1,7 +1,7 @@
//! Shared data-plane flow registration and ownership.
use std::{
net::{IpAddr, SocketAddr},
net::SocketAddr,
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
@@ -26,6 +26,16 @@ pub(crate) struct FlowKey {
pub kind: FlowKind,
}
impl FlowKey {
pub(super) fn udp_bind(local_addr: SocketAddr) -> Self {
Self {
src: local_addr,
dst: SocketAddr::from(([0, 0, 0, 0], 0)),
kind: FlowKind::Udp,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct FlowCountChange {
pub previous: usize,
@@ -136,14 +146,12 @@ impl<V> FlowTable<V> {
self.entries.is_empty()
}
pub fn contains_key(&self, entry: &FlowKey) -> bool {
self.entries.contains_key(entry)
pub(super) fn registration(&self, entry: &FlowKey) -> Option<u64> {
self.entries.get(entry).map(|flow| flow.registration)
}
pub fn contains_destination_ip(&self, destination: IpAddr) -> bool {
self.entries
.iter()
.any(|entry| entry.key().dst.ip() == destination)
pub fn contains_key(&self, entry: &FlowKey) -> bool {
self.entries.contains_key(entry)
}
#[cfg(test)]
@@ -252,7 +260,7 @@ impl<V> FlowTable<V> {
fn increment_count(&self) -> FlowCountChange {
let previous = self
.count
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |count| {
.try_update(Ordering::AcqRel, Ordering::Acquire, |count| {
count.checked_add(1)
})
.expect("flow count overflow");
@@ -294,7 +302,7 @@ impl<V> FlowTable<V> {
let previous = self
.count
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |count| {
.try_update(Ordering::AcqRel, Ordering::Acquire, |count| {
count.checked_sub(delta)
})
.expect("flow count underflow");
@@ -376,7 +384,6 @@ mod tests {
assert!(!table.try_insert(first.clone(), 2));
assert!(table.try_insert(second.clone(), 3));
assert_eq!(table.count(), 2);
assert!(table.contains_destination_ip(first.dst.ip()));
let retained = table.retain(|entry, _| entry == &second);
assert_eq!(retained.removed, 1);
+74 -50
View File
@@ -42,7 +42,7 @@ use crate::{
},
packet::{PacketType, ZCPacket},
peers::{
PeerPacketFilter,
PeerPacketFilter, PeerPacketFilterResult,
peer_manager::{PeerManagerCore, PipelineRegistrationGuard},
},
socket::{
@@ -52,7 +52,7 @@ use crate::{
VirtualTcpListener, VirtualTcpListenerFactory, VirtualTcpSocket,
VirtualTcpSocketFactory,
},
udp::{UdpBindOptions, VirtualUdpSocket, VirtualUdpSocketFactory},
udp::VirtualUdpSocketFactory,
},
};
@@ -69,6 +69,9 @@ mod tcp;
#[cfg(test)]
mod tests;
mod udp;
mod udp_fragments;
use udp_fragments::UdpFragments;
use self::{
deadline::{DataPlaneDeadline, DataPlaneIoDeadline},
@@ -169,7 +172,6 @@ pub(super) enum FlowData {
Udp,
}
const UDP_ENTRY: FlowKind = FlowKind::Udp;
const TCP_ENTRY: FlowKind = FlowKind::Tcp;
const TCP_LISTEN_ENTRY: FlowKind = FlowKind::TcpListen;
@@ -195,6 +197,7 @@ where
net: Arc<Mutex<Option<SmoltcpPlane>>>,
pub(super) entries: FlowSet,
udp_fragments: Arc<std::sync::Mutex<UdpFragments>>,
data_plane_consumers: Arc<DataPlaneConsumers>,
// Tracks whether the smoltcp `net` is ready for data-plane callers.
@@ -207,8 +210,8 @@ impl<H> PeerPacketFilter for DataPlaneRuntime<H>
where
H: VirtualTcpSocketFactory + VirtualTcpListenerFactory + VirtualUdpSocketFactory,
{
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> Option<ZCPacket> {
if self.entries.is_idle() {
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> PeerPacketFilterResult {
if self.entries.is_idle() && !packet::is_udp_fragment(&packet) {
if tracing::enabled!(tracing::Level::TRACE)
&& let Some(hdr) = packet.peer_manager_header()
&& matches!(
@@ -257,11 +260,11 @@ where
);
}
}
return Some(packet);
return PeerPacketFilterResult::Pass(packet);
}
let route = self.entries.route_peer_packet(&packet, true);
let (entry_key, tcp_flags) = match route {
PeerPacketRoute::Pass => return Some(packet),
PeerPacketRoute::Pass => return PeerPacketFilterResult::Pass(packet),
PeerPacketRoute::Unmatched { entry, tcp_flags } => {
tracing::trace!(
entry_key = ?entry,
@@ -271,34 +274,20 @@ where
entry_count = self.entries.count(),
"data plane has no flow for packet from peer"
);
return Some(packet);
return PeerPacketFilterResult::Pass(packet);
}
PeerPacketRoute::Deliver { entry, tcp_flags } => (entry, tcp_flags),
PeerPacketRoute::FragmentedUdp { source, mirror } => {
let source: IpAddr = source.into();
tracing::trace!(
is_in_entries = mirror,
"ipv4 src = {:?}, check need send both smoltcp and kernel tun",
source
);
if mirror {
// if the packet is fragmented, no matther what the payload is, need send it to both smoltcp and kernel tun. because
// we cannot determine the udp port of the packet.
match self.packet_sender.try_send(packet.clone()) {
Ok(()) => tracing::trace!(
?source,
entry_count = self.entries.count(),
"data plane delivered fragmented packet from peer to smoltcp"
),
Err(err) => tracing::trace!(
?source,
?err,
entry_count = self.entries.count(),
"data plane failed to deliver fragmented packet from peer to smoltcp"
),
}
PeerPacketRoute::FragmentedUdp { destination } => {
let local_ip = Self::runtime_ipv4(&self.runtime_config).map(|ip| ip.address());
if Some(destination) != local_ip {
return PeerPacketFilterResult::Pass(packet);
}
return Some(packet);
return self.udp_fragments.lock().unwrap().process(
packet,
local_ip,
&self.entries,
&self.packet_sender,
);
}
};
@@ -327,7 +316,7 @@ where
),
}
None
PeerPacketFilterResult::Consumed
}
}
@@ -359,6 +348,7 @@ where
net: Arc::new(Mutex::new(None)),
entries: Arc::new(FlowTable::default()),
udp_fragments: Arc::new(std::sync::Mutex::new(UdpFragments::default())),
data_plane_consumers: Arc::new(DataPlaneConsumers::new()),
data_plane_net_ready: tokio::sync::watch::channel(false).0,
@@ -392,12 +382,21 @@ where
let peer_manager = self.peer_manager.clone();
let packet_recv = self.packet_recv.clone();
let entries = self.entries.clone();
let udp_fragments = self.udp_fragments.clone();
let data_plane_consumers = self.data_plane_consumers.clone();
let data_plane_net_ready = self.data_plane_net_ready.clone();
self.runtime_tasks.lock().unwrap().spawn(async move {
let mut prev_ipv4 = None;
let mut fragment_gc = tokio::time::interval(Duration::from_secs(1));
let mut peer_changes = runtime_config.subscribe_peer_runtime_changes();
loop {
// Run housekeeping regardless of which select branch woke us.
let cur_ipv4 = Self::runtime_ipv4(&runtime_config);
{
let mut fragments = udp_fragments.lock().unwrap();
fragments.set_local_ip(cur_ipv4.map(|ip| ip.address()));
fragments.remove_expired();
}
let data_plane_active = data_plane_consumers.has_consumers();
if !data_plane_active {
@@ -429,12 +428,12 @@ where
let _ = data_plane_net_ready.send_replace(false);
select! {
_ = peer_changes.changed() => {}
_ = fragment_gc.tick() => {}
_ = data_plane_consumers.changed() => {}
}
continue;
}
let cur_ipv4 = Self::runtime_ipv4(&runtime_config);
if prev_ipv4 != cur_ipv4 {
let old_ipv4 = prev_ipv4;
prev_ipv4 = cur_ipv4;
@@ -487,6 +486,7 @@ where
select! {
_ = peer_changes.changed() => {}
_ = fragment_gc.tick() => {}
_ = data_plane_consumers.changed() => {}
}
}
@@ -549,6 +549,7 @@ where
let _ = self.data_plane_net_ready.send_replace(false);
self.entries.clear();
Self::shutdown_tasks(&self.runtime_tasks).await;
*self.udp_fragments.lock().unwrap() = UdpFragments::default();
}
pub(crate) async fn stop_runtime(&self) {
@@ -943,19 +944,44 @@ where
) -> DataPlaneResult<DataPlaneUdpSocket> {
let data_plane_ref = self.acquire_data_plane_ref()?;
let (ipv4_addr, smoltcp_net, generation) = self.wait_data_plane_net(deadline).await?;
let reservation_options = UdpBindOptions::port_lease(SocketAddr::new(
IpAddr::V4(Ipv4Addr::UNSPECIFIED),
local_port,
))
.with_context(self.socket_context.clone());
let reservation = generation
.while_open(deadline.run(self.host.bind_udp(reservation_options)))
.await?;
let reserved_port = reservation
.local_addr()
.map_err(DataPlaneError::from)?
.port();
let bind_addr = SocketAddr::new(IpAddr::V4(ipv4_addr.address()), reserved_port);
// The bound endpoint is also the port lease. Reserve it before creating
// the smoltcp socket, which does not check other sockets for duplicates.
let try_register = |port| {
let local_addr = SocketAddr::new(ipv4_addr.address().into(), port);
FlowLease::try_register(
self.entries.clone(),
FlowKey::udp_bind(local_addr),
FlowData::Udp,
)
.map(|flow| (local_addr, flow))
};
let (bind_addr, bind_flow) = if local_port != 0 {
generation.ensure_open()?;
deadline.remaining()?;
try_register(local_port).ok_or_else(|| {
DataPlaneError::new(
DataPlaneErrorKind::AddressInUse,
format!("data-plane UDP port {local_port} is already bound"),
)
})?
} else {
let mut binding = None;
for port in smoltcp_net.ephemeral_ports() {
generation.ensure_open()?;
deadline.remaining()?;
binding = try_register(port);
if binding.is_some() {
break;
}
}
binding.ok_or_else(|| {
DataPlaneError::new(
DataPlaneErrorKind::ResourceLimit,
"no free data-plane UDP ports",
)
})?
};
let smol = generation
.while_open(deadline.run(smoltcp_net.udp_bind(bind_addr)))
.await?;
@@ -965,10 +991,8 @@ where
Ok(DataPlaneUdpSocket {
socket,
flows: self.entries.clone(),
routes: std::sync::Mutex::new(std::collections::HashMap::new()),
_bind_flow: bind_flow,
local_addr,
_reservation: reservation,
_data_plane_lease: data_plane_ref,
generation,
})
+73 -46
View File
@@ -4,10 +4,7 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use smoltcp::wire::{IPV4_HEADER_LEN, IpProtocol, Ipv4Packet, TcpPacket, UdpPacket};
use crate::{
gateway::proxy::ip_reassembler::IpReassembler,
packet::{PacketType, ZCPacket},
};
use crate::packet::{PacketType, ZCPacket};
use super::flow::{FlowKey, FlowKind, FlowTable};
@@ -22,7 +19,7 @@ enum ClassifiedPeerPacket {
entry: FlowKey,
},
FragmentedUdp {
source: Ipv4Addr,
destination: Ipv4Addr,
},
Unsupported,
}
@@ -38,8 +35,7 @@ pub(crate) enum PeerPacketRoute {
tcp_flags: Option<u8>,
},
FragmentedUdp {
source: Ipv4Addr,
mirror: bool,
destination: Ipv4Addr,
},
}
fn classify_peer_ipv4_payload(payload: &[u8]) -> ClassifiedPeerPacket {
@@ -72,9 +68,9 @@ fn classify_peer_ipv4_payload(payload: &[u8]) -> ClassifiedPeerPacket {
}
}
IpProtocol::Udp => {
if IpReassembler::is_packet_fragmented(&ipv4) {
if ipv4.frag_offset() != 0 || ipv4.more_frags() {
return ClassifiedPeerPacket::FragmentedUdp {
source: ipv4.src_addr(),
destination: ipv4.dst_addr(),
};
}
let Ok(udp) = UdpPacket::new_checked(ipv4.payload()) else {
@@ -92,6 +88,34 @@ fn classify_peer_ipv4_payload(payload: &[u8]) -> ClassifiedPeerPacket {
}
}
pub(super) fn is_udp_fragment(packet: &ZCPacket) -> bool {
is_data_plane_packet(packet)
&& Ipv4Packet::new_checked(packet.payload()).is_ok_and(|ip| {
ip.version() == 4
&& usize::from(ip.header_len()) >= IPV4_HEADER_LEN
&& ip.next_header() == IpProtocol::Udp
&& (ip.frag_offset() != 0 || ip.more_frags())
})
}
fn is_data_plane_packet(packet: &ZCPacket) -> bool {
let Some(header) = packet.peer_manager_header() else {
return false;
};
let is_modified_source = matches!(
header.packet_type,
x if x == PacketType::DataWithKcpSrcModified as u8
|| x == PacketType::DataWithQuicSrcModified as u8
);
if header.packet_type != PacketType::Data as u8 && !is_modified_source {
return false;
}
if is_modified_source && header.from_peer_id != header.to_peer_id {
return false;
}
true
}
pub(super) fn tcp_flags<T: AsRef<[u8]>>(tcp: &TcpPacket<T>) -> u8 {
u8::from(tcp.fin())
| (u8::from(tcp.syn()) << 1)
@@ -108,18 +132,7 @@ impl<V> FlowTable<V> {
packet: &ZCPacket,
allow_tcp_listen_fallback: bool,
) -> PeerPacketRoute {
let Some(header) = packet.peer_manager_header() else {
return PeerPacketRoute::Pass;
};
let is_modified_source = matches!(
header.packet_type,
x if x == PacketType::DataWithKcpSrcModified as u8
|| x == PacketType::DataWithQuicSrcModified as u8
);
if header.packet_type != PacketType::Data as u8 && !is_modified_source {
return PeerPacketRoute::Pass;
}
if is_modified_source && header.from_peer_id != header.to_peer_id {
if !is_data_plane_packet(packet) {
return PeerPacketRoute::Pass;
}
@@ -144,12 +157,9 @@ impl<V> FlowTable<V> {
};
(entry, Some(flags))
}
ClassifiedPeerPacket::Udp { entry } => (entry, None),
ClassifiedPeerPacket::FragmentedUdp { source } => {
return PeerPacketRoute::FragmentedUdp {
source,
mirror: self.contains_destination_ip(source.into()),
};
ClassifiedPeerPacket::Udp { entry } => (FlowKey::udp_bind(entry.src), None),
ClassifiedPeerPacket::FragmentedUdp { destination } => {
return PeerPacketRoute::FragmentedUdp { destination };
}
ClassifiedPeerPacket::Unsupported => return PeerPacketRoute::Pass,
};
@@ -234,7 +244,7 @@ mod tests {
assert_eq!(
classify_peer_ipv4_payload(&fragmented),
ClassifiedPeerPacket::FragmentedUdp {
source: Ipv4Addr::new(10, 1, 1, 2),
destination: Ipv4Addr::new(10, 2, 2, 3),
}
);
}
@@ -314,34 +324,51 @@ mod tests {
);
}
#[test]
fn flow_table_routes_fragmented_udp_by_source_ip() {
fn flow_table_routes_udp_by_bound_port() {
let mut packet = ipv4_packet(IpProtocol::Udp, 8);
Ipv4Packet::new_unchecked(&mut packet).set_frag_offset(8);
let mut ipv4 = Ipv4Packet::new_unchecked(&mut packet);
let mut udp = UdpPacket::new_unchecked(ipv4.payload_mut());
udp.set_src_port(1234);
udp.set_dst_port(4321);
udp.set_len(8);
let table = FlowTable::default();
let entry = FlowKey::udp_bind("10.2.2.3:4321".parse().unwrap());
assert_eq!(
table.route_peer_ipv4_payload(&packet, false),
PeerPacketRoute::FragmentedUdp {
source: Ipv4Addr::new(10, 1, 1, 2),
mirror: false,
PeerPacketRoute::Unmatched {
entry: entry.clone(),
tcp_flags: None
}
);
table.insert(
FlowKey {
src: "10.2.2.3:4321".parse().unwrap(),
dst: "10.1.1.2:1234".parse().unwrap(),
kind: FlowKind::Udp,
},
(),
);
table.insert(entry.clone(), ());
assert_eq!(
table.route_peer_ipv4_payload(&packet, false),
PeerPacketRoute::FragmentedUdp {
source: Ipv4Addr::new(10, 1, 1, 2),
mirror: true,
PeerPacketRoute::Deliver {
entry,
tcp_flags: None
}
);
for more_frags in [false, true] {
let mut ipv4 = Ipv4Packet::new_unchecked(&mut packet);
ipv4.set_frag_offset(8);
ipv4.set_more_frags(more_frags);
assert_eq!(
table.route_peer_ipv4_payload(&packet, false),
PeerPacketRoute::FragmentedUdp {
destination: Ipv4Addr::new(10, 2, 2, 3),
}
);
}
for len in 0..smoltcp::wire::UDP_HEADER_LEN {
let mut short = ipv4_packet(IpProtocol::Udp, len);
Ipv4Packet::new_unchecked(&mut short).set_more_frags(true);
assert_eq!(
table.route_peer_ipv4_payload(&short, false),
PeerPacketRoute::FragmentedUdp {
destination: Ipv4Addr::new(10, 2, 2, 3)
}
);
}
}
#[test]
fn flow_table_routes_loopback_modified_source_packets() {
@@ -6,7 +6,7 @@ use std::{
sync::{Arc, Mutex},
};
use crossbeam::atomic::AtomicCell;
use crossbeam_utils::atomic::AtomicCell;
use tokio::sync::Notify;
use tokio_util::sync::CancellationToken;
+9 -1
View File
@@ -77,7 +77,9 @@ impl SmoltcpPlane {
tracing::debug!("smoltcp-to-peer bridge stopped");
});
let interface_config = smoltcp::iface::Config::new(smoltcp::wire::HardwareAddress::Ip);
// A fixed seed replays IP IDs after the last consumer closes and the
// stack is recreated, potentially reusing a remote shallow-cache route.
let interface_config = Self::interface_config(rand::random());
let net = Net::new(
device,
NetConfig::new(
@@ -117,6 +119,12 @@ impl SmoltcpPlane {
}
}
pub(super) fn interface_config(random_seed: u64) -> smoltcp::iface::Config {
let mut config = smoltcp::iface::Config::new(smoltcp::wire::HardwareAddress::Ip);
config.random_seed = random_seed;
config
}
pub(super) fn lease(&self) -> DataPlaneIoGuard {
self.generation.clone()
}
+363 -131
View File
@@ -1,6 +1,6 @@
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use smoltcp::wire::{IpAddress, IpProtocol, Ipv4Packet, TcpPacket};
use smoltcp::wire::{IpAddress, IpProtocol, Ipv4Packet, TcpPacket, UdpPacket};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use super::*;
@@ -36,6 +36,7 @@ fn test_gateway() -> Arc<DataPlaneRuntime<TestHost>> {
packet_recv: Arc::new(Mutex::new(packet_recv)),
net: Arc::new(Mutex::new(None)),
entries: Arc::new(FlowTable::default()),
udp_fragments: Arc::new(std::sync::Mutex::new(UdpFragments::default())),
data_plane_consumers: Arc::new(DataPlaneConsumers::new()),
data_plane_net_ready: tokio::sync::watch::channel(false).0,
pipeline_guard: Mutex::new(None),
@@ -196,26 +197,45 @@ fn build_tcp_packet(src: SocketAddr, dst: SocketAddr) -> Vec<u8> {
buf
}
fn build_udp_followup_fragment(src: Ipv4Addr, dst: Ipv4Addr) -> Vec<u8> {
let mut buf = vec![0u8; 28];
{
let mut ip_packet = Ipv4Packet::new_unchecked(&mut buf);
ip_packet.set_version(4);
ip_packet.set_header_len(20);
ip_packet.set_total_len(28);
ip_packet.set_hop_limit(64);
ip_packet.set_next_header(IpProtocol::Udp);
ip_packet.set_frag_offset(8);
ip_packet.set_src_addr(src);
ip_packet.set_dst_addr(dst);
ip_packet
.payload_mut()
.copy_from_slice(&[0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xba, 0xbe]);
pub(super) const UDP_LOCAL_IP: Ipv4Addr = Ipv4Addr::new(10, 144, 144, 1);
const UDP_LOCAL: SocketAddr = SocketAddr::new(IpAddr::V4(UDP_LOCAL_IP), 40000);
const UDP_REMOTE: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 144, 144, 3)), 53);
ip_packet.fill_checksum();
pub(super) fn build_udp_fragments() -> [ZCPacket; 2] {
let src_ip = Ipv4Addr::new(10, 144, 144, 3);
let dst_ip = UDP_LOCAL_IP;
let mut udp = [0; 24];
{
let mut packet = UdpPacket::new_unchecked(&mut udp[..]);
packet.set_src_port(UDP_REMOTE.port());
packet.set_dst_port(UDP_LOCAL.port());
packet.set_len(24);
packet.payload_mut().copy_from_slice(b"fragmented-first");
packet.fill_checksum(&IpAddress::Ipv4(src_ip), &IpAddress::Ipv4(dst_ip));
}
buf
[(&udp[..16], 0, true), (&udp[16..], 16, false)].map(|(payload, offset, more_fragments)| {
let mut buf = vec![0; 20 + payload.len()];
let mut ip = Ipv4Packet::new_unchecked(&mut buf);
ip.set_version(4);
ip.set_header_len(20);
ip.set_total_len((20 + payload.len()) as u16);
ip.set_ident(42);
ip.set_hop_limit(64);
ip.set_next_header(IpProtocol::Udp);
ip.set_src_addr(src_ip);
ip.set_dst_addr(dst_ip);
ip.set_frag_offset(offset);
ip.set_more_frags(more_fragments);
ip.payload_mut().copy_from_slice(payload);
ip.fill_checksum();
let mut packet = ZCPacket::new_with_payload(&buf);
packet.fill_peer_manager_hdr(1, 2, PacketType::Data as u8);
let header = packet.mut_peer_manager_header().unwrap();
header.set_latency_first(true).set_no_proxy(true);
header.forward_counter = 3;
packet
})
}
#[tokio::test]
@@ -440,18 +460,6 @@ async fn data_plane_sessions_report_udp_truncation() {
.unwrap()
.unwrap();
let warmup = session_b
.submit_udp_send(socket_b, addr_a, b"warmup".to_vec())
.unwrap();
wait_for_session_completion(&session_b).await;
session_b
.take_result_with(warmup, |outcome| match outcome {
Ok(DataPlaneOperationResult::UdpSent { len }) => Some(*len),
_ => None,
})
.unwrap()
.unwrap();
let receive = session_b.submit_udp_receive(socket_b, 2).unwrap();
let send = session_a
.submit_udp_send(socket_a, addr_b, b"ping".to_vec())
@@ -541,37 +549,39 @@ async fn listener_and_accepted_stream_own_independent_flow_lifetimes() {
stop_data_plane_pair(&a, &b).await;
}
async fn send_udp(sender: &DataPlaneUdpSocket, receiver: &DataPlaneUdpSocket, data: &[u8]) {
sender.send_to(data, receiver.local_addr()).await.unwrap();
let mut buf = vec![0; data.len()];
let (len, from) = tokio::time::timeout(Duration::from_secs(10), receiver.recv_from(&mut buf))
.await
.expect("UDP receive timed out")
.unwrap();
assert_eq!(&buf[..len], data);
assert_eq!(from, sender.local_addr());
}
#[tokio::test]
async fn data_plane_udp_pingpong() {
let (a, b) = setup_data_plane_pair().await;
let timeout = Duration::from_secs(10);
let socket_a = a.gateway.data_plane_udp_bind(0, timeout).await.unwrap();
let socket_b = b.gateway.data_plane_udp_bind(0, timeout).await.unwrap();
let addr_a = SocketAddr::new(a.ip.address().into(), socket_a.local_addr().port());
let addr_b = SocketAddr::new(b.ip.address().into(), socket_b.local_addr().port());
socket_b.send_to(b"warmup", addr_a).await.unwrap();
socket_a.send_to(b"ping", addr_b).await.unwrap();
let mut buf = [0u8; 16];
let (len, from) = tokio::time::timeout(timeout, socket_b.recv_from(&mut buf))
.await
.expect("receive ping timed out")
.unwrap();
assert_eq!(&buf[..len], b"ping");
assert_eq!(from, addr_a);
socket_b.send_to(b"pong", addr_a).await.unwrap();
loop {
let (len, from) = tokio::time::timeout(timeout, socket_a.recv_from(&mut buf))
.await
.expect("receive pong timed out")
.unwrap();
if &buf[..len] == b"pong" {
assert_eq!(from, addr_b);
break;
}
let first = a.gateway.data_plane_udp_bind(0, timeout).await.unwrap();
let second = a.gateway.data_plane_udp_bind(0, timeout).await.unwrap();
let receiver = b.gateway.data_plane_udp_bind(0, timeout).await.unwrap();
assert_ne!(first.local_addr(), second.local_addr());
for sender in [&first, &second] {
assert_ne!(sender.local_addr().port(), 0);
assert_eq!(sender.local_addr().ip(), IpAddr::V4(a.ip.address()));
send_udp(sender, &receiver, b"ping").await;
send_udp(&receiver, sender, b"pong").await;
}
// Sending to different destinations must not create per-remote flows.
assert_eq!(a.gateway.entries.count(), 2);
assert_eq!(b.gateway.entries.count(), 1);
assert_eq!(a.gateway.host.udp_binds.load(Ordering::Relaxed), 0);
assert_eq!(b.gateway.host.udp_binds.load(Ordering::Relaxed), 0);
drop((first, second, receiver));
assert_eq!(a.gateway.entries.count(), 0);
assert_eq!(b.gateway.entries.count(), 0);
stop_data_plane_pair(&a, &b).await;
}
@@ -581,60 +591,125 @@ async fn data_plane_udp_carries_maximum_ipv4_payload() {
let timeout = Duration::from_secs(10);
let socket_a = a.gateway.data_plane_udp_bind(0, timeout).await.unwrap();
let socket_b = b.gateway.data_plane_udp_bind(0, timeout).await.unwrap();
let addr_a = SocketAddr::new(a.ip.address().into(), socket_a.local_addr().port());
let addr_b = SocketAddr::new(b.ip.address().into(), socket_b.local_addr().port());
socket_b.send_to(b"warmup", addr_a).await.unwrap();
let payload = vec![0x5a; 65_507];
socket_a.send_to(&payload, addr_b).await.unwrap();
let mut received = vec![0; payload.len()];
let (len, from) = tokio::time::timeout(timeout, socket_b.recv_from(&mut received))
.await
.expect("receive maximum UDP payload timed out")
.unwrap();
assert_eq!(from, addr_a);
assert_eq!(len, payload.len());
assert_eq!(received, payload);
send_udp(&socket_a, &socket_b, &vec![0x5a; 65_507]).await;
stop_data_plane_pair(&a, &b).await;
}
#[tokio::test]
async fn udp_socket_drop_releases_every_destination_flow() {
async fn udp_fixed_ports_are_owned_by_each_instance_until_socket_drop() {
let (a, b) = setup_data_plane_pair().await;
let timeout = Duration::from_secs(10);
let socket = a.gateway.data_plane_udp_bind(0, timeout).await.unwrap();
let first = SocketAddr::new(b.ip.address().into(), 31001);
let second = SocketAddr::new(b.ip.address().into(), 31002);
socket.send_to(b"one", first).await.unwrap();
socket.send_to(b"two", second).await.unwrap();
assert_eq!(a.gateway.entries.count(), 2);
drop(socket);
let first = a.gateway.data_plane_udp_bind(48000, timeout).await.unwrap();
let second = b.gateway.data_plane_udp_bind(48000, timeout).await.unwrap();
let error = a
.gateway
.data_plane_udp_bind(48000, timeout)
.await
.err()
.unwrap();
assert_eq!(error.kind(), DataPlaneErrorKind::AddressInUse);
assert_eq!(a.gateway.entries.count(), 1);
send_udp(&first, &second, b"same-port").await;
let addr = first.local_addr();
drop(first);
assert_eq!(a.gateway.entries.count(), 0);
let rebound = a.gateway.data_plane_udp_bind(48000, timeout).await.unwrap();
assert_eq!(rebound.local_addr(), addr);
send_udp(&second, &rebound, b"rebound").await;
stop_data_plane_pair(&a, &b).await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn concurrent_udp_binds_have_exactly_one_owner() {
let host = Arc::new(TestHost::default());
let endpoint = data_plane_endpoint(host, "10.126.132.1/24".parse().unwrap());
endpoint.peer_manager.run().await.unwrap();
endpoint.gateway.start_runtime().await.unwrap();
let barrier = Arc::new(tokio::sync::Barrier::new(8));
let mut tasks = JoinSet::new();
for _ in 0..8 {
let gateway = endpoint.gateway.clone();
let barrier = barrier.clone();
tasks.spawn(async move {
barrier.wait().await;
gateway
.data_plane_udp_bind(48000, Duration::from_secs(10))
.await
});
}
let mut sockets = Vec::new();
while let Some(result) = tasks.join_next().await {
match result.unwrap() {
Ok(socket) => sockets.push(socket),
Err(error) => assert_eq!(error.kind(), DataPlaneErrorKind::AddressInUse),
}
}
assert_eq!(sockets.len(), 1);
assert_eq!(endpoint.gateway.entries.count(), 1);
assert_eq!(endpoint.gateway.host.udp_binds.load(Ordering::Relaxed), 0);
drop(sockets);
assert_eq!(endpoint.gateway.entries.count(), 0);
endpoint.gateway.stop_runtime().await;
endpoint.peer_manager.clear_resources().await;
}
#[tokio::test]
async fn udp_socket_owns_a_host_port_reservation() {
async fn udp_ephemeral_allocation_advances_past_collisions() {
let endpoint = data_plane_endpoint(
Arc::new(TestHost::default()),
"10.126.132.1/24".parse().unwrap(),
);
endpoint.peer_manager.run().await.unwrap();
endpoint.gateway.start_runtime().await.unwrap();
let gateway = &endpoint.gateway;
let timeout = Duration::from_secs(10);
let fixed = gateway.data_plane_udp_bind(11000, timeout).await.unwrap();
for port in 10001..11000 {
let addr = SocketAddr::new(endpoint.ip.address().into(), port);
gateway
.entries
.insert(FlowKey::udp_bind(addr), FlowData::Udp);
}
let socket = gateway.data_plane_udp_bind(0, timeout).await.unwrap();
assert_eq!(socket.local_addr().port(), 11001);
let net = gateway.net.lock().await.as_ref().unwrap().net.clone();
assert_eq!(net.get_port(), 11002);
let next = gateway.data_plane_udp_bind(0, timeout).await.unwrap();
assert_eq!(next.local_addr().port(), 11003);
let mut slow = net.ephemeral_ports();
let last = net.ephemeral_ports().nth(10).unwrap();
slow.nth(2);
assert_eq!(net.get_port(), last + 1);
drop((fixed, socket, next));
gateway.stop_runtime().await;
endpoint.peer_manager.clear_resources().await;
}
#[tokio::test]
async fn udp_ephemeral_bind_wraps_and_reports_exhaustion() {
let host = Arc::new(TestHost::default());
let endpoint = data_plane_endpoint(host.clone(), "10.126.132.1/24".parse().unwrap());
endpoint.peer_manager.run().await.unwrap();
endpoint.gateway.start_runtime().await.unwrap();
let gateway = &endpoint.gateway;
let timeout = Duration::from_secs(10);
let fixed = gateway.data_plane_udp_bind(60001, timeout).await.unwrap();
// Leave only the port reached after wrapping past the end of the range.
for port in 10001..60001 {
let addr = SocketAddr::new(endpoint.ip.address().into(), port);
gateway
.entries
.insert(FlowKey::udp_bind(addr), FlowData::Udp);
}
let socket = gateway.data_plane_udp_bind(0, timeout).await.unwrap();
assert_eq!(socket.local_addr().port(), 10000);
let error = gateway.data_plane_udp_bind(0, timeout).await.err().unwrap();
assert_eq!(error.kind(), DataPlaneErrorKind::ResourceLimit);
assert_eq!(host.udp_binds.load(Ordering::Relaxed), 0);
let socket = endpoint
.gateway
.data_plane_udp_bind(0, Duration::from_secs(1))
.await
.unwrap();
assert_eq!(socket.local_addr().port(), 20002);
assert_eq!(host.udp_binds.load(Ordering::Relaxed), 1);
drop(socket);
endpoint.gateway.stop_runtime().await;
drop((fixed, socket));
gateway.stop_runtime().await;
endpoint.peer_manager.clear_resources().await;
}
@@ -833,7 +908,7 @@ async fn data_plane_consumes_modified_data_when_entry_matches() {
packet.fill_peer_manager_hdr(1, 1, packet_type as u8);
let result = gateway.try_process_packet_from_peer(packet).await;
assert!(result.is_none());
assert!(matches!(result, PeerPacketFilterResult::Consumed));
let mut receiver = gateway.packet_recv.lock().await;
let received = receiver.try_recv().unwrap();
@@ -864,12 +939,12 @@ async fn data_plane_passes_through_unmatched_or_malformed_modified_data() {
ZCPacket::new_with_payload(&build_tcp_packet(remote, unmatched_local));
unmatched_packet.fill_peer_manager_hdr(1, 2, PacketType::DataWithKcpSrcModified as u8);
let result = gateway.try_process_packet_from_peer(unmatched_packet).await;
assert!(result.is_some());
assert!(matches!(result, PeerPacketFilterResult::Pass(_)));
let mut malformed_packet = ZCPacket::new_with_payload(&[0u8; 8]);
malformed_packet.fill_peer_manager_hdr(1, 2, PacketType::DataWithQuicSrcModified as u8);
let result = gateway.try_process_packet_from_peer(malformed_packet).await;
assert!(result.is_some());
assert!(matches!(result, PeerPacketFilterResult::Pass(_)));
let mut receiver = gateway.packet_recv.lock().await;
assert!(receiver.try_recv().is_err());
@@ -897,47 +972,204 @@ async fn data_plane_passes_through_non_loopback_modified_data_when_entry_matches
packet.fill_peer_manager_hdr(1, 2, PacketType::DataWithKcpSrcModified as u8);
let result = gateway.try_process_packet_from_peer(packet).await;
assert!(result.is_some());
assert!(matches!(result, PeerPacketFilterResult::Pass(_)));
let mut receiver = gateway.packet_recv.lock().await;
assert!(receiver.try_recv().is_err());
}
#[tokio::test]
async fn data_plane_mirrors_fragmented_udp_when_entry_matches() {
async fn data_plane_routes_udp_fragments_together() {
for port in [None, Some(UDP_LOCAL.port() + 1), Some(UDP_LOCAL.port())] {
for reverse in [false, true] {
let gateway = test_gateway();
gateway.runtime_config.update_peer_with(|peer| {
peer.runtime.core.routes.ipv4 =
Some(IpPrefix::new(UDP_LOCAL_IP.into(), 24).unwrap());
});
if let Some(port) = port {
gateway.entries.insert(
FlowKey::udp_bind(SocketAddr::new(UDP_LOCAL_IP.into(), port)),
FlowData::Udp,
);
}
let mut downstream = Vec::new();
let mut delivered = Vec::new();
let mut expected = Vec::new();
// More than smoltcp's 16 slots, with every tail arriving before any first.
for index in if reverse { [1, 0] } else { [0, 1] } {
for id in 0..32 {
let mut packet = build_udp_fragments().into_iter().nth(index).unwrap();
let mut ip = Ipv4Packet::new_unchecked(packet.mut_payload());
ip.set_ident(id);
ip.fill_checksum();
expected.push(packet.clone().into_bytes());
match gateway.try_process_packet_from_peer(packet).await {
PeerPacketFilterResult::Consumed => {}
PeerPacketFilterResult::Pass(packet) => {
downstream.push(packet.into_bytes())
}
PeerPacketFilterResult::PassBatch(packets) => {
downstream.extend(packets.into_iter().map(ZCPacket::into_bytes))
}
}
let mut receiver = gateway.packet_recv.lock().await;
while let Ok(packet) = receiver.try_recv() {
delivered.push(packet.into_bytes());
}
}
}
expected.sort();
downstream.sort();
delivered.sort();
if port == Some(UDP_LOCAL.port()) {
assert_eq!(delivered, expected);
assert!(downstream.is_empty());
} else {
assert_eq!(downstream, expected);
assert!(delivered.is_empty());
}
}
}
}
#[tokio::test]
async fn data_plane_only_buffers_eligible_local_udp_fragments() {
let gateway = test_gateway();
let local = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 144, 144, 1)), 40000);
let remote = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 144, 144, 3)), 53);
gateway.entries.insert(
FlowKey {
src: local,
dst: remote,
kind: UDP_ENTRY,
},
FlowData::Udp,
);
assert_eq!(gateway.entries.count(), 1);
let mut packet = ZCPacket::new_with_payload(&build_udp_followup_fragment(
match remote.ip() {
IpAddr::V4(ip) => ip,
IpAddr::V6(_) => unreachable!(),
},
match local.ip() {
IpAddr::V4(ip) => ip,
IpAddr::V6(_) => unreachable!(),
},
));
packet.fill_peer_manager_hdr(1, 2, PacketType::Data as u8);
let result = gateway.try_process_packet_from_peer(packet).await;
assert!(result.is_some());
gateway.runtime_config.update_peer_with(|peer| {
peer.runtime.core.routes.ipv4 = Some(IpPrefix::new(UDP_LOCAL_IP.into(), 24).unwrap());
});
gateway
.entries
.insert(FlowKey::udp_bind(UDP_LOCAL), FlowData::Udp);
for (destination, packet_type, to_peer, buffered) in [
(UDP_LOCAL_IP, PacketType::Data, 2, true),
(Ipv4Addr::new(10, 144, 144, 9), PacketType::Data, 2, false),
(UDP_LOCAL_IP, PacketType::DataWithKcpSrcModified, 1, true),
(UDP_LOCAL_IP, PacketType::DataWithKcpSrcModified, 2, false),
(UDP_LOCAL_IP, PacketType::DataWithQuicSrcModified, 1, true),
(UDP_LOCAL_IP, PacketType::DataWithQuicSrcModified, 2, false),
] {
let [_, mut tail] = build_udp_fragments();
let mut ip = Ipv4Packet::new_unchecked(tail.mut_payload());
ip.set_dst_addr(destination);
ip.fill_checksum();
tail.fill_peer_manager_hdr(1, to_peer, packet_type as u8);
let expected = tail.clone().into_bytes();
let result = gateway.try_process_packet_from_peer(tail).await;
if buffered {
assert!(matches!(result, PeerPacketFilterResult::Consumed));
} else {
let PeerPacketFilterResult::Pass(packet) = result else {
panic!("unrelated fragment buffered")
};
assert_eq!(packet.into_bytes(), expected);
}
assert!(gateway.packet_recv.lock().await.try_recv().is_err());
}
}
let mut receiver = gateway.packet_recv.lock().await;
let received = receiver.try_recv().unwrap();
assert_eq!(
received.peer_manager_header().unwrap().packet_type,
PacketType::Data as u8
#[tokio::test]
async fn data_plane_udp_receives_fragments_after_unbound_datagrams() {
let endpoint = data_plane_endpoint(
Arc::new(TestHost::default()),
"10.144.144.1/24".parse().unwrap(),
);
endpoint.peer_manager.run().await.unwrap();
endpoint.gateway.start_runtime().await.unwrap();
let timeout = Duration::from_secs(10);
let socket = endpoint
.gateway
.data_plane_udp_bind(UDP_LOCAL.port(), timeout)
.await
.unwrap();
// Unrelated complete datagrams must never leave orphan tails in smoltcp.
for id in 0..32 {
for mut packet in build_udp_fragments() {
let mut ip = Ipv4Packet::new_unchecked(packet.mut_payload());
ip.set_ident(id);
if ip.frag_offset() == 0 {
let mut udp = UdpPacket::new_unchecked(ip.payload_mut());
udp.set_dst_port(UDP_LOCAL.port() + 1);
udp.set_checksum(0);
}
ip.fill_checksum();
assert!(matches!(
endpoint.gateway.try_process_packet_from_peer(packet).await,
PeerPacketFilterResult::Pass(_)
));
}
}
let [first, tail] = build_udp_fragments();
for packet in [tail, first] {
assert!(matches!(
endpoint.gateway.try_process_packet_from_peer(packet).await,
PeerPacketFilterResult::Consumed
));
}
let mut data = [0; 32];
let (len, from) = tokio::time::timeout(timeout, socket.recv_from(&mut data))
.await
.unwrap()
.unwrap();
assert_eq!(&data[..len], b"fragmented-first");
assert_eq!(from, UDP_REMOTE);
// A datagram larger than either MTU remains a set of original small packets.
for port in [UDP_LOCAL.port(), UDP_LOCAL.port() + 1] {
let mut udp = vec![0; 8 + 4096];
let mut udp_packet = UdpPacket::new_unchecked(&mut udp);
udp_packet.set_src_port(UDP_REMOTE.port());
udp_packet.set_dst_port(port);
udp_packet.set_len(4104);
udp_packet.payload_mut().fill(0xab);
udp_packet.fill_checksum(&UDP_REMOTE.ip().into(), &UDP_LOCAL.ip().into());
let [template, _] = build_udp_fragments();
let mut fragments = Vec::new();
for (index, payload) in udp.chunks(1200).enumerate() {
let mut bytes = template.payload()[..20].to_vec();
bytes.extend_from_slice(payload);
let mut ip = Ipv4Packet::new_unchecked(&mut bytes);
ip.set_ident(port);
ip.set_total_len((20 + payload.len()) as u16);
ip.set_frag_offset((index * 1200) as u16);
ip.set_more_frags((index + 1) * 1200 < udp.len());
ip.fill_checksum();
let mut packet = ZCPacket::new_with_payload(&bytes);
packet.fill_peer_manager_hdr(1, 2, PacketType::Data as u8);
fragments.push(packet);
}
let mut expected = fragments
.iter()
.map(|p| p.clone().into_bytes())
.collect::<Vec<_>>();
let mut passed = Vec::new();
for packet in fragments.into_iter().rev() {
match endpoint.gateway.try_process_packet_from_peer(packet).await {
PeerPacketFilterResult::Consumed => {}
PeerPacketFilterResult::Pass(_) => panic!("tails should wait for the first"),
PeerPacketFilterResult::PassBatch(packets) => {
assert!(packets.iter().all(|packet| packet.payload_len() <= 1280));
passed.extend(packets.into_iter().map(ZCPacket::into_bytes));
}
}
}
if port == UDP_LOCAL.port() {
assert!(passed.is_empty());
let mut data = vec![0; 4096];
let (len, from) = tokio::time::timeout(timeout, socket.recv_from(&mut data))
.await
.unwrap()
.unwrap();
assert_eq!(len, 4096);
assert_eq!(data, vec![0xab; 4096]);
assert_eq!(from, UDP_REMOTE);
} else {
expected.sort();
passed.sort();
assert_eq!(passed, expected);
}
}
drop(socket);
endpoint.gateway.stop_runtime().await;
endpoint.peer_manager.clear_resources().await;
}
+3 -28
View File
@@ -1,23 +1,13 @@
//! UDP socket resources exposed by the data plane.
use std::{
any::Any,
collections::{HashMap, hash_map::Entry},
net::SocketAddr,
sync::{Arc, Mutex},
};
use std::{net::SocketAddr, sync::Arc};
use super::{
DataPlaneIoGuard, DataPlaneLease, DataPlaneUdpIo, FlowData, FlowKey, FlowLease, FlowSet,
UDP_ENTRY,
};
use super::{DataPlaneIoGuard, DataPlaneLease, DataPlaneUdpIo, FlowData, FlowLease};
pub struct DataPlaneUdpSocket {
pub(super) socket: Arc<DataPlaneUdpIo>,
pub(super) flows: FlowSet,
pub(super) routes: Mutex<HashMap<SocketAddr, FlowLease<FlowData>>>,
pub(super) _bind_flow: FlowLease<FlowData>,
pub(super) local_addr: SocketAddr,
pub(super) _reservation: Arc<dyn Any + Send + Sync>,
pub(super) _data_plane_lease: DataPlaneLease,
pub(super) generation: DataPlaneIoGuard,
}
@@ -31,21 +21,6 @@ impl DataPlaneUdpSocket {
self.generation
.ensure_open()
.map_err(|error| error.into_io_error())?;
let key = FlowKey {
src: self.local_addr,
dst: addr,
kind: UDP_ENTRY,
};
if let Entry::Vacant(route) = self.routes.lock().unwrap().entry(addr) {
let lease = FlowLease::try_register(self.flows.clone(), key, FlowData::Udp)
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::AddrInUse,
"data-plane UDP flow already exists",
)
})?;
route.insert(lease);
}
tokio::select! {
biased;
_ = self.generation.closed() => Err(self.generation.closed_io_error()),
@@ -0,0 +1,381 @@
//! Route original UDP fragments together without assembling their payloads.
use std::{
net::{Ipv4Addr, SocketAddr},
time::Duration,
};
use ordered_hash_map::OrderedHashMap;
use smoltcp::wire::{Ipv4Packet, UDP_HEADER_LEN, UdpPacket};
use tokio::{sync::mpsc, time::Instant};
use super::{FlowKey, FlowSet, PeerPacketFilterResult};
use crate::packet::ZCPacket;
#[derive(Clone, Copy, Hash, PartialEq, Eq)]
struct UdpFragmentKey {
source: Ipv4Addr,
destination: Ipv4Addr,
id: u16,
from_peer: u32,
to_peer: u32,
packet_type: u8,
exit_node: bool,
no_proxy: bool,
not_send_to_tun: bool,
}
struct UdpFragmentRoute {
entry: FlowKey,
registration: Option<u64>,
}
struct UdpFragment {
last_seen: Instant,
route: Option<UdpFragmentRoute>,
pending: Vec<ZCPacket>,
bytes: usize,
}
impl UdpFragment {
fn new(now: Instant) -> Self {
Self {
last_seen: now,
route: None,
pending: Vec::new(),
bytes: 0,
}
}
}
#[derive(Default)]
pub(super) struct UdpFragments {
local_ip: Option<Ipv4Addr>,
packets: OrderedHashMap<UdpFragmentKey, UdpFragment>,
bytes: usize,
}
impl UdpFragments {
const CAPACITY: usize = 1024;
// Budget retained packet bytes, not allocator overhead.
const MAX_BYTES: usize = 1024 * 1024;
// A shallow cache cannot detect completion. Randomizing each sending stack's
// seed avoids deterministic IP ID reuse after a restart, but 16-bit IDs can
// still collide. We accept occasional UDP loss or delivery to the cached
// downstream route instead of tracking fragment ranges to detect completion.
// Hits refresh this idle timeout, so repeated collisions can prolong it.
// Expired/evicted unknown tails wait for a first fragment.
const TIMEOUT: Duration = Duration::from_secs(1);
pub(super) fn set_local_ip(&mut self, local_ip: Option<Ipv4Addr>) {
if self.local_ip != local_ip {
*self = Self {
local_ip,
..Self::default()
};
}
}
fn evict_oldest(&mut self) {
if let Some(packet) = self.packets.pop_front() {
self.bytes -= packet.bytes;
}
}
pub(super) fn remove_expired(&mut self) {
let now = Instant::now();
while self
.packets
.front()
.is_some_and(|packet| now.duration_since(packet.last_seen) >= Self::TIMEOUT)
{
self.evict_oldest();
}
}
pub(super) fn process(
&mut self,
packet: ZCPacket,
local_ip: Option<Ipv4Addr>,
flows: &FlowSet,
sender: &mpsc::Sender<ZCPacket>,
) -> PeerPacketFilterResult {
self.set_local_ip(local_ip);
// The caller has already classified a valid IPv4 UDP fragment.
let ip = Ipv4Packet::new_checked(packet.payload()).unwrap();
if Some(ip.dst_addr()) != local_ip {
return PeerPacketFilterResult::Pass(packet);
}
let payload = ip.payload();
if !ip.verify_checksum()
|| payload.is_empty()
|| (ip.more_frags() && !payload.len().is_multiple_of(8))
|| usize::from(ip.frag_offset()) + payload.len() > usize::from(u16::MAX) - 20
{
return PeerPacketFilterResult::Consumed;
}
let port = if ip.frag_offset() == 0 {
if payload.len() < UDP_HEADER_LEN {
return PeerPacketFilterResult::Consumed;
}
// Its length covers the whole datagram, not just this fragment.
let udp = UdpPacket::new_unchecked(payload);
if usize::from(udp.len()) < UDP_HEADER_LEN {
return PeerPacketFilterResult::Consumed;
}
Some(udp.dst_port())
} else {
None
};
let header = packet.peer_manager_header().unwrap();
let key = UdpFragmentKey {
source: ip.src_addr(),
destination: ip.dst_addr(),
id: ip.ident(),
from_peer: header.from_peer_id.get(),
to_peer: header.to_peer_id.get(),
packet_type: header.packet_type,
exit_node: header.is_exit_node(),
no_proxy: header.is_no_proxy(),
not_send_to_tun: header.is_not_send_to_tun(),
};
self.remove_expired();
let now = Instant::now();
let mut context = self
.packets
.get_mut(&key)
.map(|context| std::mem::replace(context, UdpFragment::new(now)))
.unwrap_or_else(|| UdpFragment::new(now));
self.bytes -= context.bytes;
context.last_seen = now;
if context.route.is_none()
&& let Some(port) = port
{
let entry = FlowKey::udp_bind(SocketAddr::new(key.destination.into(), port));
let registration = flows.registration(&entry);
context.route = Some(UdpFragmentRoute {
entry,
registration,
});
}
let result = if let Some(route) = &context.route {
context.bytes = 0;
// Preserve ownership across close/rebind, including downstream routes.
if let Some(registration) = route.registration {
if flows.registration(&route.entry) == Some(registration) {
for packet in std::iter::once(packet).chain(context.pending.drain(..)) {
if let Err(error) = sender.try_send(packet) {
tracing::trace!(?error, "data plane fragment queue full or closed");
}
}
}
context.pending = Vec::new();
PeerPacketFilterResult::Consumed
} else if context.pending.is_empty() {
PeerPacketFilterResult::Pass(packet)
} else {
context.pending.push(packet);
PeerPacketFilterResult::PassBatch(std::mem::take(&mut context.pending))
}
} else {
context.bytes += packet.buf_len();
context.pending.push(packet);
PeerPacketFilterResult::Consumed
};
self.bytes += context.bytes;
// Replacing an entry moves it to the back, keeping expiry and LRU order.
self.packets.insert(key, context);
while self.packets.len() > Self::CAPACITY || self.bytes > Self::MAX_BYTES {
self.evict_oldest();
}
result
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::super::{
FlowData, FlowTable,
tests::{UDP_LOCAL_IP, build_udp_fragments},
};
use super::*;
#[tokio::test]
async fn recreated_sending_stack_does_not_replay_fragment_routes() {
use super::super::stack::SmoltcpPlane;
use crate::gateway::smoltcp::{Net, NetConfig, channel_device};
let flows = Arc::new(FlowTable::default());
let local = SocketAddr::new(UDP_LOCAL_IP.into(), 40000);
flows.insert(FlowKey::udp_bind(local), FlowData::Udp);
let (sender, mut receiver) = mpsc::channel(16);
let mut cache = UdpFragments::default();
// Use controlled seeds: random 16-bit IDs may legitimately collide.
// Each iteration drops the old sender and creates a real new stack.
for (seed, port) in [(1, 40001), (2, 40000)] {
let mut capabilities = smoltcp::phy::DeviceCapabilities::default();
capabilities.medium = smoltcp::phy::Medium::Ip;
capabilities.max_transmission_unit = 1284;
let (device, _input, mut output) = channel_device::ChannelDevice::new(capabilities);
let net = Net::new(
device,
NetConfig::new(
SmoltcpPlane::interface_config(seed),
"10.144.144.3/24".parse().unwrap(),
Vec::new(),
None,
),
);
let socket = net
.udp_bind("10.144.144.3:53".parse().unwrap())
.await
.unwrap();
socket
.send_to(&[0xab; 4096], SocketAddr::new(UDP_LOCAL_IP.into(), port))
.await
.unwrap();
let mut count = 0;
loop {
let bytes = tokio::time::timeout(Duration::from_secs(1), output.recv())
.await
.unwrap()
.unwrap();
let ip = Ipv4Packet::new_checked(bytes.as_slice()).unwrap();
assert!(ip.more_frags() || ip.frag_offset() != 0);
let last = !ip.more_frags();
// Keep the old route live regardless of test-machine scheduling.
for context in cache.packets.values_mut() {
context.last_seen = Instant::now();
}
let mut packet = ZCPacket::new_with_payload(&bytes);
packet.fill_peer_manager_hdr(1, 2, crate::packet::PacketType::Data as u8);
let result = cache.process(packet, Some(UDP_LOCAL_IP), &flows, &sender);
if port == local.port() {
assert!(matches!(result, PeerPacketFilterResult::Consumed));
assert_eq!(receiver.try_recv().unwrap().payload(), bytes);
} else {
assert!(matches!(result, PeerPacketFilterResult::Pass(_)));
assert!(receiver.try_recv().is_err());
}
count += 1;
if last {
break;
}
}
assert_eq!(count, 4);
}
assert_eq!(cache.packets.len(), 2);
}
#[test]
fn routes_survive_binding_changes() {
for bound in [false, true] {
let flows = Arc::new(FlowTable::default());
let entry = FlowKey::udp_bind(SocketAddr::new(UDP_LOCAL_IP.into(), 40000));
if bound {
flows.insert(entry.clone(), FlowData::Udp);
}
let (sender, mut receiver) = mpsc::channel(4);
let mut cache = UdpFragments::default();
let [first, tail] = build_udp_fragments();
let result = cache.process(first, Some(UDP_LOCAL_IP), &flows, &sender);
assert_eq!(matches!(result, PeerPacketFilterResult::Consumed), bound);
if bound {
receiver.try_recv().unwrap();
}
flows.clear();
flows.insert(entry, FlowData::Udp);
let result = cache.process(tail, Some(UDP_LOCAL_IP), &flows, &sender);
assert_eq!(matches!(result, PeerPacketFilterResult::Consumed), bound);
assert!(
receiver.try_recv().is_err(),
"old fragments reached a new binding"
);
}
}
#[test]
fn cache_expires_and_resets_on_address_change() {
let flows = Arc::new(FlowTable::default());
let (sender, _receiver) = mpsc::channel(4);
let mut cache = UdpFragments::default();
let [first, tail] = build_udp_fragments();
cache.process(tail.clone(), Some(UDP_LOCAL_IP), &flows, &sender);
assert!(cache.bytes > 0);
for context in cache.packets.values_mut() {
context.last_seen -= UdpFragments::TIMEOUT;
}
cache.remove_expired();
assert!(cache.packets.is_empty());
assert_eq!(cache.bytes, 0);
assert!(matches!(
cache.process(first, Some(UDP_LOCAL_IP), &flows, &sender),
PeerPacketFilterResult::Pass(_)
));
cache.process(tail, Some(UDP_LOCAL_IP), &flows, &sender);
cache.set_local_ip(None);
assert!(cache.packets.is_empty());
assert_eq!(cache.bytes, 0);
}
#[test]
fn cache_limits_pending_bytes_and_evicts_least_recent_entry() {
let flows = Arc::new(FlowTable::default());
let (sender, _receiver) = mpsc::channel(4);
let mut cache = UdpFragments::default();
for id in 0..=UdpFragments::CAPACITY as u16 {
let [_, mut tail] = build_udp_fragments();
let mut ip = Ipv4Packet::new_unchecked(tail.mut_payload());
ip.set_ident(id);
ip.fill_checksum();
cache.process(tail, Some(UDP_LOCAL_IP), &flows, &sender);
}
assert_eq!(cache.packets.len(), UdpFragments::CAPACITY);
assert!(!cache.packets.keys().any(|key| key.id == 0));
let [_, tail] = build_udp_fragments();
cache.process(tail.clone(), Some(UDP_LOCAL_IP), &flows, &sender);
let refreshed = *cache.packets.keys().find(|key| key.id == 42).unwrap();
cache.evict_oldest();
assert!(cache.packets.contains_key(&refreshed));
cache.set_local_ip(None);
let mut payload = tail.payload().to_vec();
payload.resize(65516, 0);
let mut ip = Ipv4Packet::new_unchecked(&mut payload);
ip.set_total_len(65516);
ip.fill_checksum();
let mut large = ZCPacket::new_with_payload(&payload);
large.fill_peer_manager_hdr(1, 2, crate::packet::PacketType::Data as u8);
for _ in 0..=UdpFragments::MAX_BYTES / large.buf_len() {
cache.process(large.clone(), Some(UDP_LOCAL_IP), &flows, &sender);
assert!(cache.bytes <= UdpFragments::MAX_BYTES);
}
assert_eq!(
cache.bytes, 0,
"over-budget pending datagram was not evicted"
);
assert!(cache.packets.is_empty());
}
#[test]
fn pending_fragments_do_not_cross_peer_or_delivery_flags() {
let flows = Arc::new(FlowTable::default());
let (sender, _receiver) = mpsc::channel(4);
for change_peer in [true, false] {
let mut cache = UdpFragments::default();
let [first, mut tail] = build_udp_fragments();
if change_peer {
tail.mut_peer_manager_header().unwrap().from_peer_id.set(9);
} else {
tail.mut_peer_manager_header().unwrap().set_no_proxy(false);
}
cache.process(tail, Some(UDP_LOCAL_IP), &flows, &sender);
assert!(matches!(
cache.process(first, Some(UDP_LOCAL_IP), &flows, &sender),
PeerPacketFilterResult::Pass(_)
));
assert!(cache.bytes > 0);
}
}
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -27,6 +27,6 @@ pub use dataplane::{
DataPlaneTcpListener, DataPlaneTcpStream, DataPlaneUdpSocket,
};
#[cfg(feature = "proxy-smoltcp-stack")]
pub(crate) use port_forward::PortForwardAdapter;
pub(crate) use port_forward::{PortForwardAdapter, PreparedPortForwards};
#[cfg(feature = "proxy-smoltcp-stack")]
pub(crate) use socks5::Socks5GatewayAdapter;
+314 -103
View File
@@ -1,6 +1,7 @@
//! Host port-forward adapter backed by the data-plane runtime.
use std::{
collections::HashMap,
net::SocketAddr,
sync::{
Arc,
@@ -9,17 +10,17 @@ use std::{
time::Duration,
};
use crossbeam::atomic::AtomicCell;
use crossbeam_utils::atomic::AtomicCell;
use dashmap::DashMap;
use quanta::Instant;
use tokio::{
select,
sync::{Mutex, OwnedMutexGuard, OwnedSemaphorePermit, Semaphore},
task::JoinSet,
task::{JoinHandle, JoinSet},
};
use tokio_util::{
sync::{CancellationToken, DropGuard},
task::AbortOnDropHandle,
sync::CancellationToken,
task::{AbortOnDropHandle, TaskTracker},
};
use crate::{
@@ -94,24 +95,133 @@ struct UdpClientReservation {
admission: OwnedMutexGuard<()>,
}
struct PortForwardListener {
config: Arc<std::sync::Mutex<PortForwardConfig>>,
cancel: CancellationToken,
task: AbortOnDropHandle<()>,
udp_tasks: TaskTracker,
}
enum PortForwardSocket<H: VirtualTcpListenerFactory + VirtualUdpSocketFactory> {
Tcp(Arc<H::Listener>),
Udp(Arc<<H as VirtualUdpSocketFactory>::Socket>),
}
/// Bound additions stay private until persistence and validation have succeeded.
/// Dropping a prepared update leaves every existing listener and target intact.
pub(crate) struct PreparedPortForwards<H>
where
H: VirtualTcpSocketFactory + VirtualTcpListenerFactory + VirtualUdpSocketFactory,
{
adapter: Arc<PortForwardAdapter<H>>,
configs: HashMap<(String, SocketAddr), PortForwardConfig>,
sockets: Vec<(PortForwardConfig, PortForwardSocket<H>)>,
consumer_lease: Option<DataPlaneConsumerLease>,
// Release prepared sockets before allowing another update to bind them.
operation: OwnedMutexGuard<()>,
}
impl<H> PreparedPortForwards<H>
where
H: VirtualTcpSocketFactory + VirtualTcpListenerFactory + VirtualUdpSocketFactory,
{
/// All fallible work is complete. Publish targets and start the bound sockets
/// without yielding, then retain the operation lock until removals release
/// their sockets, even if the caller stops waiting for this cleanup.
pub(crate) fn commit(self) -> JoinHandle<()> {
let Self {
adapter,
operation,
configs,
sockets,
consumer_lease,
} = self;
let _runtime = adapter.runtime_handle.as_ref().map(|handle| handle.enter());
let mut listeners = adapter.listeners.lock().unwrap();
let mut removed = Vec::new();
let mut response_tasks = Vec::new();
let removed_keys = listeners
.keys()
.filter(|key| !configs.contains_key(*key))
.cloned()
.collect::<Vec<_>>();
for key in removed_keys {
let listener = listeners.remove(&key).unwrap();
listener.cancel.cancel();
listener.task.abort();
{
let current = listener.config.lock().unwrap();
tracing::info!(?current, "port-forward removed by runtime config reload");
adapter.remove_udp_clients(&current, &mut response_tasks);
}
removed.push(listener);
}
for (key, listener) in listeners.iter() {
let mut current = listener.config.lock().unwrap();
if let Some(next) = configs.get(key)
&& *current != *next
{
adapter.remove_udp_clients(&current, &mut response_tasks);
*current = next.clone();
adapter
.events
.emit(CoreEvent::GatewayPortForwardAdded(next.clone()));
}
}
for (cfg, socket) in sockets {
let key = (cfg.proto.clone(), cfg.bind_addr);
let listener = match socket {
PortForwardSocket::Tcp(socket) => adapter.add_tcp_port_forward(&cfg, socket),
PortForwardSocket::Udp(socket) => adapter.add_udp_port_forward(&cfg, socket),
};
listeners.insert(key, listener);
adapter.events.emit(CoreEvent::GatewayPortForwardAdded(cfg));
}
drop(listeners);
let mut lease = adapter.consumer_lease.lock().unwrap();
if let Some(consumer_lease) = consumer_lease {
lease.replace(consumer_lease);
}
let released_lease = if configs.is_empty() {
lease.take()
} else {
None
};
drop(lease);
tokio::spawn(async move {
let _operation = operation;
for listener in removed {
let _ = listener.task.await;
listener.udp_tasks.close();
listener.udp_tasks.wait().await;
}
for task in response_tasks {
let _ = task.await;
}
drop(released_lease);
})
}
}
pub(crate) struct PortForwardAdapter<H>
where
H: VirtualTcpSocketFactory + VirtualTcpListenerFactory + VirtualUdpSocketFactory,
{
operation: Mutex<()>,
operation: Arc<Mutex<()>>,
started: AtomicBool,
runtime_handle: Option<tokio::runtime::Handle>,
runtime_config: CoreRuntimeConfigStore,
data_plane: Arc<DataPlaneRuntime<H>>,
host: Arc<H>,
socket_context: SocketContext,
events: Arc<dyn CoreEventSink>,
tasks: Arc<std::sync::Mutex<JoinSet<()>>>,
cancel_tokens: Arc<DashMap<PortForwardConfig, DropGuard>>,
listeners: std::sync::Mutex<HashMap<(String, SocketAddr), PortForwardListener>>,
udp_clients: Arc<DashMap<UdpClientKey, Arc<UdpClientInfo<H>>>>,
udp_response_tasks: Arc<DashMap<UdpClientKey, AbortOnDropHandle<()>>>,
udp_client_admission: Arc<Mutex<()>>,
udp_client_slots: Arc<Semaphore>,
consumer_lease: Mutex<Option<DataPlaneConsumerLease>>,
consumer_lease: std::sync::Mutex<Option<DataPlaneConsumerLease>>,
}
impl<H> PortForwardAdapter<H>
@@ -124,27 +234,29 @@ where
host: Arc<H>,
socket_context: SocketContext,
events: Arc<dyn CoreEventSink>,
runtime_handle: Option<tokio::runtime::Handle>,
) -> Arc<Self> {
Arc::new(Self {
operation: Mutex::new(()),
operation: Arc::new(Mutex::new(())),
started: AtomicBool::new(false),
runtime_handle,
runtime_config,
data_plane,
host,
socket_context,
events,
tasks: Arc::new(std::sync::Mutex::new(JoinSet::new())),
cancel_tokens: Arc::new(DashMap::new()),
listeners: std::sync::Mutex::new(HashMap::new()),
udp_clients: Arc::new(DashMap::new()),
udp_response_tasks: Arc::new(DashMap::new()),
udp_client_admission: Arc::new(Mutex::new(())),
udp_client_slots: Arc::new(Semaphore::new(MAX_ACTIVE_UDP_CLIENTS)),
consumer_lease: Mutex::new(None),
consumer_lease: std::sync::Mutex::new(None),
})
}
pub(crate) async fn start(&self) -> anyhow::Result<()> {
let _operation = self.operation.lock().await;
pub(crate) async fn start(self: &Arc<Self>) -> anyhow::Result<()> {
let operation = self.operation.clone().lock_owned().await;
if self.started.load(Ordering::Acquire) {
return Ok(());
}
@@ -160,95 +272,157 @@ where
.gateway
.port_forwards
.clone();
if let Err(error) = self.apply_port_forwards(&cfgs).await {
self.stop_inner().await;
return Err(error);
}
let prepared = match self.prepare_inner(&cfgs, operation).await {
Ok(prepared) => prepared,
Err(error) => {
self.stop_inner().await;
return Err(error);
}
};
let cleanup = prepared.commit();
self.started.store(true, Ordering::Release);
let _ = cleanup.await;
Ok(())
}
pub(crate) async fn reload(&self, cfgs: &[PortForwardConfig]) -> anyhow::Result<()> {
let _operation = self.operation.lock().await;
if !self.started.load(Ordering::Acquire) {
return Ok(());
pub(crate) async fn prepare(
self: &Arc<Self>,
cfgs: &[PortForwardConfig],
) -> anyhow::Result<Option<PreparedPortForwards<H>>> {
if let Some(runtime) = &self.runtime_handle {
let adapter = self.clone();
let cfgs = cfgs.to_vec();
// Binding must happen on the instance executor, including reloads
// reached through management config patches.
return AbortOnDropHandle::new(
runtime.spawn(async move { adapter.prepare_on_runtime(&cfgs).await }),
)
.await
.map_err(|error| anyhow::anyhow!("port-forward prepare task failed: {error}"))?;
}
self.apply_port_forwards(cfgs).await
self.prepare_on_runtime(cfgs).await
}
async fn apply_port_forwards(&self, cfgs: &[PortForwardConfig]) -> anyhow::Result<()> {
async fn prepare_on_runtime(
self: &Arc<Self>,
cfgs: &[PortForwardConfig],
) -> anyhow::Result<Option<PreparedPortForwards<H>>> {
let operation = self.operation.clone().lock_owned().await;
if !self.started.load(Ordering::Acquire) {
return Ok(None);
}
self.prepare_inner(cfgs, operation).await.map(Some)
}
async fn prepare_inner(
self: &Arc<Self>,
cfgs: &[PortForwardConfig],
operation: OwnedMutexGuard<()>,
) -> anyhow::Result<PreparedPortForwards<H>> {
let mut configs = HashMap::new();
for cfg in cfgs {
if !matches!(cfg.proto.to_lowercase().as_str(), "tcp" | "udp") {
let mut cfg = cfg.clone();
cfg.proto.make_ascii_lowercase();
if !matches!(cfg.proto.as_str(), "tcp" | "udp") {
anyhow::bail!(
"unsupported protocol: {}, only support udp / tcp",
cfg.proto
);
}
}
if !cfgs.is_empty() {
let mut consumer_lease = self.consumer_lease.lock().await;
if consumer_lease.is_none() {
consumer_lease.replace(self.data_plane.acquire_consumer_lease()?);
let key = (cfg.proto.clone(), cfg.bind_addr);
if let Some(previous) = configs.insert(key, cfg.clone()) {
anyhow::ensure!(
previous == cfg,
"conflicting port-forward targets for {} {}",
cfg.proto,
cfg.bind_addr
);
}
}
self.cancel_tokens.retain(|current, _| {
let keep = cfgs.iter().any(|next| {
if next.dst_addr.ip().is_unspecified() {
current.bind_addr == next.bind_addr && current.proto == next.proto
} else {
current == next
}
});
if !keep {
tracing::info!(?current, "port-forward removed by runtime config reload");
}
keep
});
self.udp_clients
.retain(|key, _| self.cancel_tokens.contains_key(&key.forward));
self.udp_response_tasks
.retain(|key, _| self.udp_clients.contains_key(key));
let consumer_lease = if !configs.is_empty() && self.consumer_lease.lock().unwrap().is_none()
{
Some(self.data_plane.acquire_consumer_lease()?)
} else {
None
};
let mut sockets = Vec::new();
for cfg in cfgs {
if !self.cancel_tokens.contains_key(cfg) {
self.add_port_forward(cfg.clone()).await?;
let mut cfg = cfg.clone();
cfg.proto.make_ascii_lowercase();
let key = (cfg.proto.clone(), cfg.bind_addr);
if self.listeners.lock().unwrap().contains_key(&key)
|| sockets
.iter()
.any(|(bound, _): &(PortForwardConfig, PortForwardSocket<H>)| {
bound.proto == cfg.proto && bound.bind_addr == cfg.bind_addr
})
{
continue;
}
}
if cfgs.is_empty() {
self.consumer_lease.lock().await.take();
}
Ok(())
}
async fn add_port_forward(&self, cfg: PortForwardConfig) -> anyhow::Result<()> {
match cfg.proto.to_lowercase().as_str() {
"tcp" => self.add_tcp_port_forward(&cfg).await?,
"udp" => self.add_udp_port_forward(&cfg).await?,
_ => {
anyhow::bail!(
"unsupported protocol: {}, only support udp / tcp",
cfg.proto
let socket = if cfg.proto == "tcp" {
PortForwardSocket::Tcp(
self.host
.bind_tcp(self.tcp_listen_options(cfg.bind_addr))
.await?,
)
}
} else {
PortForwardSocket::Udp(
self.host
.bind_udp(
UdpBindOptions::port_forward(cfg.bind_addr)
.with_context(self.socket_context.clone()),
)
.await?,
)
};
sockets.push((cfg, socket));
}
self.events.emit(CoreEvent::GatewayPortForwardAdded(cfg));
Ok(())
Ok(PreparedPortForwards {
adapter: self.clone(),
operation,
configs,
sockets,
consumer_lease,
})
}
async fn add_tcp_port_forward(&self, cfg: &PortForwardConfig) -> anyhow::Result<()> {
let (bind_addr, dst_addr) = (cfg.bind_addr, cfg.dst_addr);
fn tcp_listen_options(&self, bind_addr: SocketAddr) -> TcpListenOptions {
let options = TcpListenOptions::port_forward(bind_addr);
let bind = options
.bind
.clone()
.with_context(self.socket_context.clone());
let options = options.with_bind(bind);
let listener = self.host.bind_tcp(options.clone()).await?;
options.with_bind(bind)
}
fn remove_udp_clients(&self, cfg: &PortForwardConfig, tasks: &mut Vec<AbortOnDropHandle<()>>) {
self.udp_clients.retain(|key, _| key.forward != *cfg);
let keys = self
.udp_response_tasks
.iter()
.filter(|entry| entry.key().forward == *cfg)
.map(|entry| entry.key().clone())
.collect::<Vec<_>>();
for key in keys {
if let Some((_, task)) = self.udp_response_tasks.remove(&key) {
task.abort();
tasks.push(task);
}
}
}
fn add_tcp_port_forward(
&self,
cfg: &PortForwardConfig,
listener: Arc<H::Listener>,
) -> PortForwardListener {
let (bind_addr, dst_addr) = (cfg.bind_addr, cfg.dst_addr);
let options = self.tcp_listen_options(bind_addr);
tracing::info!(?bind_addr, ?dst_addr, "TCP port-forward listener bound");
let cancel = CancellationToken::new();
self.cancel_tokens
.insert(cfg.clone(), cancel.clone().drop_guard());
let config = Arc::new(std::sync::Mutex::new(cfg.clone()));
let task_cancel = cancel.clone();
let task_config = config.clone();
let data_plane = self.data_plane.clone();
let connections = Arc::new(std::sync::Mutex::new(JoinSet::new()));
@@ -257,7 +431,8 @@ where
"TCP port-forward connections",
));
let host = self.host.clone();
self.tasks.lock().unwrap().spawn(async move {
let task = AbortOnDropHandle::new(tokio::spawn(async move {
let cancel = task_cancel;
let mut listener = Some(listener);
loop {
let Some((incoming, source_addr)) =
@@ -265,6 +440,7 @@ where
else {
break;
};
let dst_addr = task_config.lock().unwrap().dst_addr;
tracing::info!(
?bind_addr,
?source_addr,
@@ -299,22 +475,27 @@ where
?dst_addr,
"TCP port-forward listener task stopped"
);
});
Ok(())
}));
PortForwardListener {
config,
cancel,
task,
udp_tasks: TaskTracker::new(),
}
}
async fn add_udp_port_forward(&self, cfg: &PortForwardConfig) -> anyhow::Result<()> {
let (bind_addr, dst_addr) = (cfg.bind_addr, cfg.dst_addr);
let forward = cfg.clone();
let socket = self
.host
.bind_udp(
UdpBindOptions::port_forward(bind_addr).with_context(self.socket_context.clone()),
)
.await?;
fn add_udp_port_forward(
&self,
cfg: &PortForwardConfig,
socket: Arc<<H as VirtualUdpSocketFactory>::Socket>,
) -> PortForwardListener {
let bind_addr = cfg.bind_addr;
let cancel = CancellationToken::new();
self.cancel_tokens
.insert(cfg.clone(), cancel.clone().drop_guard());
let config = Arc::new(std::sync::Mutex::new(cfg.clone()));
let task_cancel = cancel.clone();
let task_config = config.clone();
let udp_tasks = TaskTracker::new();
let response_tracker = udp_tasks.clone();
let data_plane = self.data_plane.clone();
let host = self.host.clone();
@@ -323,7 +504,8 @@ where
let response_tasks = self.udp_response_tasks.clone();
let client_admission = self.udp_client_admission.clone();
let client_slots = self.udp_client_slots.clone();
self.tasks.lock().unwrap().spawn(async move {
let task = AbortOnDropHandle::new(tokio::spawn(async move {
let cancel = task_cancel;
let adapter = UdpFlowFactory {
data_plane,
host,
@@ -342,6 +524,8 @@ where
}
},
};
let forward = task_config.lock().unwrap().clone();
let dst_addr = forward.dst_addr;
let key = UdpClientKey {
client_addr,
forward: forward.clone(),
@@ -376,6 +560,13 @@ where
continue;
};
let UdpClientReservation { slot, admission } = reservation;
// Commit uses this same lock when replacing the target
// and removing its flows. Do not publish an old flow
// after either operation, including after an async open.
let current = task_config.lock().unwrap();
if *current != forward || cancel.is_cancelled() {
continue;
}
let slot = Arc::new(slot);
let client = Arc::new(UdpClientInfo {
flow: flow.clone(),
@@ -390,7 +581,7 @@ where
let response_slot = slot;
response_tasks.insert(
key.clone(),
AbortOnDropHandle::new(tokio::spawn(async move {
AbortOnDropHandle::new(response_tracker.spawn(async move {
let _slot = response_slot;
let mut buf = vec![0u8; MAX_UDP_PAYLOAD_SIZE];
loop {
@@ -417,6 +608,7 @@ where
}
})),
);
drop(current);
drop(admission);
client
}
@@ -426,9 +618,14 @@ where
tracing::error!(?error, ?dst_addr, "send UDP data-plane packet failed");
}
}
});
}));
Ok(())
PortForwardListener {
config,
cancel,
task,
udp_tasks,
}
}
fn start_udp_reaper(&self) {
@@ -450,14 +647,29 @@ where
async fn stop_inner(&self) {
self.started.store(false, Ordering::Release);
let listeners = std::mem::take(&mut *self.listeners.lock().unwrap());
tracing::info!(
forward_count = self.cancel_tokens.len(),
forward_count = listeners.len(),
"port-forward adapter stopping"
);
self.cancel_tokens.clear();
for listener in listeners.values() {
listener.cancel.cancel();
listener.task.abort();
}
// First stop publishers, then remove response handles. The trackers
// include responses whose handles the reaper/eviction already dropped.
let mut trackers = Vec::new();
for (_, listener) in listeners {
let _ = listener.task.await;
trackers.push(listener.udp_tasks);
}
self.udp_response_tasks.clear();
self.udp_clients.clear();
self.consumer_lease.lock().await.take();
for tracker in trackers {
tracker.close();
tracker.wait().await;
}
self.consumer_lease.lock().unwrap().take();
let mut tasks = {
let mut tasks = self.tasks.lock().unwrap();
std::mem::replace(&mut *tasks, JoinSet::new())
@@ -621,6 +833,9 @@ where
}
}
#[cfg(test)]
mod reload_tests;
#[cfg(test)]
mod tests {
use std::{io, sync::atomic::AtomicUsize};
@@ -892,15 +1107,8 @@ mod tests {
response_tasks.insert(replacement.clone(), pending_response_task(replacement_slot));
drop(admission_guard);
tokio::time::timeout(Duration::from_secs(1), async {
while clients.contains_key(&replacement) {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
assert!(!response_tasks.contains_key(&replacement));
// Release the test's permit reference and wait for the full eviction;
// the client and response-task entries are removed separately.
drop(replacement_client);
let second_slot = tokio::time::timeout(Duration::from_secs(1), second)
.await
@@ -908,6 +1116,9 @@ mod tests {
.unwrap()
.unwrap();
assert!(!clients.contains_key(&replacement));
assert!(!response_tasks.contains_key(&replacement));
drop(second_slot);
tokio::task::yield_now().await;
assert_eq!(slots.available_permits(), 1);
@@ -0,0 +1,290 @@
use std::{io, sync::Weak};
use tokio::sync::{Notify, mpsc, oneshot};
use super::*;
use crate::{
config::{IpPrefix, peers::PeerRuntimeSnapshot, runtime::CoreRuntimeConfig},
host::{
packet::{HostPacketReceiver, host_packet_channel},
testkit::TestHost,
},
peers::peer_manager::{PeerManagerCore, PortablePeerManagerConfig},
socket::tcp::TcpConnectOptions,
};
type Datagram = (Vec<u8>, SocketAddr);
struct ReloadUdpSocket {
address: SocketAddr,
incoming: Option<Mutex<mpsc::UnboundedReceiver<Datagram>>>,
sent: mpsc::UnboundedSender<Datagram>,
}
#[async_trait::async_trait]
impl VirtualUdpSocket for ReloadUdpSocket {
fn local_addr(&self) -> io::Result<SocketAddr> {
Ok(self.address)
}
async fn send_to(&self, data: &[u8], address: SocketAddr) -> io::Result<usize> {
self.sent.send((data.to_vec(), address)).unwrap();
Ok(data.len())
}
async fn recv_from(&self, buf: &mut [u8]) -> io::Result<(usize, SocketAddr)> {
let Some(incoming) = &self.incoming else {
return std::future::pending().await;
};
let (data, address) = incoming.lock().await.recv().await.unwrap();
buf[..data.len()].copy_from_slice(&data);
Ok((data.len(), address))
}
}
struct ReloadUdpHost {
incoming: std::sync::Mutex<Option<mpsc::UnboundedReceiver<Datagram>>>,
sent: mpsc::UnboundedSender<Datagram>,
sockets: std::sync::Mutex<Vec<Weak<ReloadUdpSocket>>>,
open_started: Notify,
open_release: std::sync::Mutex<Option<oneshot::Receiver<()>>>,
}
#[async_trait::async_trait]
impl VirtualTcpSocketFactory for ReloadUdpHost {
type Socket = <TestHost as VirtualTcpSocketFactory>::Socket;
async fn connect_tcp(&self, options: TcpConnectOptions) -> anyhow::Result<Self::Socket> {
TestHost::default().connect_tcp(options).await
}
}
#[async_trait::async_trait]
impl VirtualTcpListenerFactory for ReloadUdpHost {
type Listener = <TestHost as VirtualTcpListenerFactory>::Listener;
async fn bind_tcp(&self, options: TcpListenOptions) -> anyhow::Result<Arc<Self::Listener>> {
TestHost::default().bind_tcp(options).await
}
}
#[async_trait::async_trait]
impl VirtualUdpSocketFactory for ReloadUdpHost {
type Socket = ReloadUdpSocket;
async fn bind_udp(&self, options: UdpBindOptions) -> anyhow::Result<Arc<Self::Socket>> {
let address = options.local_addr.unwrap();
let incoming = if address.port() != 0 {
self.incoming.lock().unwrap().take().map(Mutex::new)
} else {
let release = self.open_release.lock().unwrap().take();
if let Some(release) = release {
self.open_started.notify_one();
release.await.unwrap();
}
None
};
let socket = Arc::new(ReloadUdpSocket {
address,
incoming,
sent: self.sent.clone(),
});
self.sockets.lock().unwrap().push(Arc::downgrade(&socket));
Ok(socket)
}
}
struct UdpReloadTest {
adapter: Arc<PortForwardAdapter<ReloadUdpHost>>,
host: Arc<ReloadUdpHost>,
incoming: mpsc::UnboundedSender<Datagram>,
sent: mpsc::UnboundedReceiver<Datagram>,
_peer_manager: Arc<PeerManagerCore>,
_packets: HostPacketReceiver,
}
impl UdpReloadTest {
async fn new() -> Self {
let mut runtime = PeerRuntimeSnapshot::default().runtime;
runtime.core.routes.ipv4 = Some(IpPrefix::new("10.144.0.2".parse().unwrap(), 24).unwrap());
let peer_config = PortablePeerManagerConfig::new(runtime);
let runtime_config = CoreRuntimeConfigStore::new(
CoreRuntimeConfig::default(),
Arc::new(peer_config.snapshot.clone()),
);
let (packet_sender, packets) = host_packet_channel();
let peer_manager =
Arc::new(PeerManagerCore::new_portable_for_test(peer_config, packet_sender).unwrap());
let (incoming, receiver) = mpsc::unbounded_channel();
let (sent, sent_receiver) = mpsc::unbounded_channel();
let host = Arc::new(ReloadUdpHost {
incoming: std::sync::Mutex::new(Some(receiver)),
sent,
sockets: std::sync::Mutex::new(Vec::new()),
open_started: Notify::new(),
open_release: std::sync::Mutex::new(None),
});
let data_plane = DataPlaneRuntime::new(
runtime_config.clone(),
peer_manager.clone(),
None,
host.clone(),
SocketContext::default(),
);
data_plane.start_runtime().await.unwrap();
let adapter = PortForwardAdapter::new(
runtime_config,
data_plane,
host.clone(),
SocketContext::default(),
Arc::new(()),
None,
);
adapter.start().await.unwrap();
Self {
adapter,
host,
incoming,
sent: sent_receiver,
_peer_manager: peer_manager,
_packets: packets,
}
}
async fn apply(&self, port: u16) -> PortForwardConfig {
let config = PortForwardConfig {
proto: "udp".to_owned(),
bind_addr: "127.0.0.1:19491".parse().unwrap(),
dst_addr: SocketAddr::from(([10, 144, 0, 2], port)),
};
self.adapter
.prepare(std::slice::from_ref(&config))
.await
.unwrap()
.unwrap()
.commit()
.await
.unwrap();
config
}
fn send(&self, payload: &[u8]) {
self.incoming
.send((payload.to_vec(), "127.0.0.1:40000".parse().unwrap()))
.unwrap();
}
async fn receive(&mut self) -> Datagram {
tokio::time::timeout(Duration::from_secs(2), self.sent.recv())
.await
.unwrap()
.unwrap()
}
async fn stop(&self) {
self.adapter.stop().await;
self.adapter.data_plane.stop_runtime().await;
}
}
#[tokio::test]
async fn udp_target_update_reuses_listener_and_releases_old_flow() {
let mut test = UdpReloadTest::new().await;
let first = test.apply(19381).await;
test.send(b"first");
assert_eq!(test.receive().await, (b"first".to_vec(), first.dst_addr));
let second = test.apply(19382).await;
assert!(test.adapter.udp_clients.is_empty());
{
let sockets = test.host.sockets.lock().unwrap();
assert_eq!(
sockets.len(),
2,
"target changes must not bind a new listener"
);
assert!(sockets[0].upgrade().is_some());
assert!(sockets[1].upgrade().is_none(), "old flow must be released");
}
test.send(b"second");
assert_eq!(test.receive().await, (b"second".to_vec(), second.dst_addr));
assert_eq!(test.adapter.udp_clients.len(), 1);
test.stop().await;
}
#[tokio::test]
async fn udp_target_update_during_open_discards_old_flow() {
let mut test = UdpReloadTest::new().await;
test.apply(19381).await;
let (release, waiting) = oneshot::channel();
*test.host.open_release.lock().unwrap() = Some(waiting);
test.send(b"old");
tokio::time::timeout(Duration::from_secs(2), test.host.open_started.notified())
.await
.unwrap();
let next = test.apply(19382).await;
release.send(()).unwrap();
test.send(b"new");
assert_eq!(test.receive().await, (b"new".to_vec(), next.dst_addr));
assert!(
test.adapter
.udp_clients
.iter()
.all(|entry| entry.key().forward == next)
);
assert!(test.host.sockets.lock().unwrap()[1].upgrade().is_none());
test.stop().await;
}
#[cfg(not(any(target_arch = "wasm32", target_os = "wasi")))]
#[tokio::test]
async fn udp_removal_waits_for_response_tasks_already_removed_from_map() {
let test = UdpReloadTest::new().await;
let forward = test.apply(19381).await;
let socket = test.host.sockets.lock().unwrap()[0].upgrade().unwrap();
let tracker = test
.adapter
.listeners
.lock()
.unwrap()
.get(&(forward.proto.clone(), forward.bind_addr))
.unwrap()
.udp_tasks
.clone();
let (started, ready) = oneshot::channel();
let (release, waiting) = std::sync::mpsc::channel();
// A running blocking task makes the delay between abort and socket release
// deterministic, as can happen when a response task is currently executing.
let task = tracker.spawn_blocking(move || {
started.send(()).unwrap();
waiting.recv().unwrap();
drop(socket);
});
let key = UdpClientKey {
client_addr: "127.0.0.1:40000".parse().unwrap(),
forward,
};
test.adapter
.udp_response_tasks
.insert(key, AbortOnDropHandle::new(task));
ready.await.unwrap();
test.adapter.udp_response_tasks.clear();
let cleanup = test.adapter.prepare(&[]).await.unwrap().unwrap().commit();
drop(cleanup);
let mut next = std::pin::pin!(test.adapter.prepare(&[]));
let result = tokio::time::timeout(Duration::from_millis(20), &mut next).await;
let pending = result.is_err();
release.send(()).unwrap();
let prepared = match result {
Ok(prepared) => prepared,
Err(_) => tokio::time::timeout(Duration::from_secs(2), next)
.await
.unwrap(),
};
prepared.unwrap().unwrap().commit().await.unwrap();
assert!(
pending,
"dropping the cleanup waiter released the operation lock before the socket"
);
assert!(test.host.sockets.lock().unwrap()[0].upgrade().is_none());
test.stop().await;
}
@@ -15,7 +15,7 @@ use tokio::{
use crate::{
packet::ZCPacket,
peers::{
PeerPacketFilter,
PeerPacketFilter, PeerPacketFilterResult,
peer_manager::{PeerManagerCore, PipelineRegistrationGuard},
},
};
@@ -249,11 +249,11 @@ struct IcmpProxyServiceFilter<R: IcmpProxyRuntime + 'static> {
#[async_trait::async_trait]
impl<R: IcmpProxyRuntime + 'static> PeerPacketFilter for IcmpProxyServiceFilter<R> {
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> Option<ZCPacket> {
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> PeerPacketFilterResult {
let Some(service) = self.service.upgrade() else {
return Some(packet);
return PeerPacketFilterResult::Pass(packet);
};
service.handle_peer_packet(packet).await
service.handle_peer_packet(packet).await.into()
}
}
@@ -8,7 +8,7 @@ use std::{
};
use cidr::Ipv4Inet;
use crossbeam::atomic::AtomicCell;
use crossbeam_utils::atomic::AtomicCell;
use dashmap::{DashMap, mapref::entry::Entry};
use smoltcp::wire::{IpAddress, IpProtocol, Ipv4Packet, TcpPacket};
@@ -186,7 +186,7 @@ impl TcpProxyEngine {
for _ in 0..u16::MAX {
let translated_port = self
.next_translated_port
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |port| {
.try_update(Ordering::Relaxed, Ordering::Relaxed, |port| {
Some(if port == u16::MAX { 1 } else { port + 1 })
})
.expect("translated port counter update cannot fail");
@@ -11,7 +11,7 @@ use crate::{
foundation::time::timeout,
packet::ZCPacket,
peers::{
NicPacketFilter, PeerPacketFilter,
NicPacketFilter, PeerPacketFilter, PeerPacketFilterResult,
peer_manager::{PeerManagerCore, PipelineRegistrationGuard},
},
socket::{
@@ -544,11 +544,11 @@ struct TcpProxyServiceFilter<
impl<R: TcpProxyRuntime + 'static, F: VirtualTcpListenerFactory, C: TcpProxyDestinationConnector>
PeerPacketFilter for TcpProxyServiceFilter<R, F, C>
{
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> Option<ZCPacket> {
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> PeerPacketFilterResult {
let Some(service) = self.service.upgrade() else {
return Some(packet);
return PeerPacketFilterResult::Pass(packet);
};
service.handle_peer_packet(packet).await
service.handle_peer_packet(packet).await.into()
}
}
@@ -9,8 +9,8 @@ use tokio::sync::mpsc::{self, Receiver, Sender};
use tokio::task::JoinSet;
use crate::packet::ZCPacket;
use crate::peers::PeerPacketFilter;
use crate::peers::peer_manager::{PeerManagerCore, PipelineRegistrationGuard};
use crate::peers::{PeerPacketFilter, PeerPacketFilterResult};
use super::cidr_table::ProxyCidrTable;
use super::traits::{UdpProxyResponseSink, UdpProxyRuntime};
@@ -208,10 +208,10 @@ struct UdpProxyServiceFilter<R: UdpProxyRuntime + 'static> {
#[async_trait::async_trait]
impl<R: UdpProxyRuntime + 'static> PeerPacketFilter for UdpProxyServiceFilter<R> {
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> Option<ZCPacket> {
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> PeerPacketFilterResult {
let Some(service) = self.service.upgrade() else {
return Some(packet);
return PeerPacketFilterResult::Pass(packet);
};
service.handle_peer_packet(packet).await
service.handle_peer_packet(packet).await.into()
}
}
@@ -12,7 +12,7 @@ use crate::{
listener::RunningListenerRegistry,
packet::{PacketType, ZCPacket, ZCPacketType},
peers::{
PeerPacketFilter,
PeerPacketFilter, PeerPacketFilterResult,
peer_manager::{PeerManagerCore, PipelineRegistrationGuard},
},
process_runtime::ProtectedTcpPortRegistry,
@@ -147,15 +147,15 @@ struct WrappedTransportPeerFilter {
#[async_trait]
impl PeerPacketFilter for WrappedTransportPeerFilter {
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> Option<ZCPacket> {
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> PeerPacketFilterResult {
let Some(header) = packet.peer_manager_header() else {
return Some(packet);
return PeerPacketFilterResult::Pass(packet);
};
if header.packet_type != self.transport.incoming_packet_type(self.role) as u8 {
return Some(packet);
return PeerPacketFilterResult::Pass(packet);
}
let Some(engine) = self.engine.upgrade() else {
return Some(packet);
return PeerPacketFilterResult::Pass(packet);
};
let from_peer_id = header.from_peer_id.get();
if let Err(error) = engine
@@ -169,7 +169,7 @@ impl PeerPacketFilter for WrappedTransportPeerFilter {
"failed to inject wrapped transport packet"
);
}
None
PeerPacketFilterResult::Consumed
}
}
@@ -797,7 +797,10 @@ mod tests {
let mut packet = ZCPacket::new_with_payload(b"payload");
packet.fill_peer_manager_hdr(7, 1, PacketType::KcpDst as u8);
assert!(filter.try_process_packet_from_peer(packet).await.is_none());
assert!(matches!(
filter.try_process_packet_from_peer(packet).await,
PeerPacketFilterResult::Consumed
));
assert_eq!(
*engine.injections.lock().unwrap(),
[(
@@ -811,7 +814,10 @@ mod tests {
drop(engine);
let mut packet = ZCPacket::new_with_payload(b"stale");
packet.fill_peer_manager_hdr(7, 1, PacketType::KcpDst as u8);
assert!(filter.try_process_packet_from_peer(packet).await.is_some());
assert!(matches!(
filter.try_process_packet_from_peer(packet).await,
PeerPacketFilterResult::Pass(_)
));
}
#[test]
@@ -82,6 +82,9 @@ impl std::fmt::Debug for Net {
}
impl Net {
const EPHEMERAL_PORT_START: u16 = 10000;
const EPHEMERAL_PORT_END: u16 = 60001;
/// Creates a new `Net` instance. It panics if the medium is not supported.
pub fn new<D: device::AsyncDevice + 'static>(device: D, config: NetConfig) -> Net {
Self::new2(device, config)
@@ -119,18 +122,44 @@ impl Net {
Net {
reactor: Arc::new(reactor),
ip_addr: config.ip_addr,
from_port: AtomicU16::new(10001),
from_port: AtomicU16::new(Self::EPHEMERAL_PORT_START + 1),
stopper,
_fut: AbortOnDropHandle::new(tokio::spawn(fut)),
}
}
fn next_port(port: u16) -> u16 {
if port >= Self::EPHEMERAL_PORT_END {
Self::EPHEMERAL_PORT_START
} else {
port + 1
}
}
pub fn get_port(&self) -> u16 {
self.from_port
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |x| {
Some(if x > 60000 { 10000 } else { x + 1 })
.try_update(Ordering::SeqCst, Ordering::SeqCst, |port| {
Some(Self::next_port(port))
})
.unwrap()
}
/// Visit each ephemeral port once, independently of concurrent allocations.
pub(crate) fn ephemeral_ports(&self) -> impl Iterator<Item = u16> {
let start = self.get_port();
std::iter::once(start).chain(
(start + 1..=Self::EPHEMERAL_PORT_END)
.chain(Self::EPHEMERAL_PORT_START..start)
.inspect(|&port| {
// Follow the scan, but never overwrite another allocator's progress.
let _ = self.from_port.compare_exchange(
port,
Self::next_port(port),
Ordering::SeqCst,
Ordering::SeqCst,
);
}),
)
}
/// Creates a new TcpListener, which will be bound to the specified address.
pub async fn tcp_bind(&self, addr: SocketAddr) -> io::Result<TcpListener> {
let addr = self.set_address(addr);
@@ -188,3 +217,36 @@ impl Drop for Net {
self.stopper.notify_waiters()
}
}
#[cfg(test)]
mod tests {
use super::*;
use smoltcp::{phy::Medium, wire::HardwareAddress};
#[allow(clippy::unused_async)]
#[tokio::test]
async fn port_allocation_returns_previous_value_and_wraps() {
let mut capabilities = device::DeviceCapabilities::default();
capabilities.medium = Medium::Ip;
capabilities.max_transmission_unit = 1280;
let (device, _ingress, _egress) = channel_device::ChannelDevice::new(capabilities);
let net = Net::new(
device,
NetConfig::new(
Config::new(HardwareAddress::Ip),
"10.126.126.1/24".parse().unwrap(),
Vec::new(),
None,
),
);
assert_eq!(net.get_port(), 10001);
assert_eq!(net.get_port(), 10002);
net.from_port.store(60000, Ordering::SeqCst);
assert_eq!(net.get_port(), 60000);
assert_eq!(net.get_port(), 60001);
assert_eq!(net.get_port(), 10000);
assert_eq!(net.get_port(), 10001);
}
}
+58 -7
View File
@@ -6,11 +6,12 @@
//! accept, UDP sockets never receive, and DNS answers only literal IP hosts.
use std::{
collections::HashMap,
io,
net::{IpAddr, SocketAddr},
pin::Pin,
sync::{
Arc,
Arc, Mutex,
atomic::{AtomicUsize, Ordering},
},
task::{Context, Poll},
@@ -70,11 +71,16 @@ impl VirtualTcpSocket for TestTcpSocket {
pub struct TestTcpListener {
address: SocketAddr,
active_listeners: Arc<AtomicUsize>,
listeners: Option<Arc<Mutex<TestTcpListeners>>>,
incoming: Option<tokio::sync::Mutex<tokio::sync::mpsc::UnboundedReceiver<TestTcpSocket>>>,
}
impl Drop for TestTcpListener {
fn drop(&mut self) {
self.active_listeners.fetch_sub(1, Ordering::Relaxed);
if let Some(listeners) = &self.listeners {
listeners.lock().unwrap().remove(&self.address);
}
}
}
@@ -87,6 +93,12 @@ impl VirtualTcpListener for TestTcpListener {
}
async fn accept(&self) -> io::Result<(Self::Socket, SocketAddr)> {
if let Some(incoming) = &self.incoming {
let socket = incoming.lock().await.recv().await.ok_or_else(|| {
io::Error::new(io::ErrorKind::ConnectionAborted, "test listener closed")
})?;
return Ok((socket, "127.0.0.1:20001".parse().unwrap()));
}
std::future::pending().await
}
}
@@ -108,6 +120,8 @@ impl VirtualUdpSocket for TestUdpSocket {
}
}
pub type TestTcpListeners = HashMap<SocketAddr, tokio::sync::mpsc::UnboundedSender<TestTcpSocket>>;
#[derive(Default)]
pub struct TestHost {
pub tcp_binds: AtomicUsize,
@@ -115,21 +129,42 @@ pub struct TestHost {
pub udp_binds: AtomicUsize,
pub proxy_nat_connections:
Option<tokio::sync::mpsc::UnboundedSender<(SocketAddr, tokio::io::DuplexStream)>>,
pub port_forward_connections:
Option<tokio::sync::mpsc::UnboundedSender<(SocketAddr, tokio::io::DuplexStream)>>,
pub tcp_listeners: Option<Arc<Mutex<TestTcpListeners>>>,
pub reject_tcp_bind: Mutex<Option<SocketAddr>>,
pub reject_socks5_listener: bool,
}
impl TestHost {
#[cfg(all(feature = "management", feature = "proxy-smoltcp-stack"))]
pub fn connect_to_listener(&self, address: SocketAddr) -> tokio::io::DuplexStream {
let (socket, peer) = tokio::io::duplex(1024);
self.tcp_listeners
.as_ref()
.unwrap()
.lock()
.unwrap()
.get(&address)
.expect("test listener must be bound")
.send(TestTcpSocket(socket))
.unwrap();
peer
}
}
#[async_trait::async_trait]
impl VirtualTcpSocketFactory for TestHost {
type Socket = TestTcpSocket;
async fn connect_tcp(&self, options: TcpConnectOptions) -> anyhow::Result<Self::Socket> {
if options.purpose != TcpSocketPurpose::ProxyNat {
anyhow::bail!("test host does not connect non-proxy TCP sockets");
let connections = match options.purpose {
TcpSocketPurpose::ProxyNat => &self.proxy_nat_connections,
TcpSocketPurpose::PortForward => &self.port_forward_connections,
_ => anyhow::bail!("test host does not connect non-proxy TCP sockets"),
}
let connections = self
.proxy_nat_connections
.as_ref()
.ok_or_else(|| anyhow::anyhow!("test host proxy NAT is disabled"))?;
.as_ref()
.ok_or_else(|| anyhow::anyhow!("test host TCP connections are disabled"))?;
let (socket, peer) = tokio::io::duplex(1024);
connections
.send((options.remote_addr, peer))
@@ -159,10 +194,26 @@ impl VirtualTcpListenerFactory for TestHost {
} else {
address
};
if *self.reject_tcp_bind.lock().unwrap() == Some(address) {
anyhow::bail!("test host rejected TCP bind to {address}");
}
let incoming = if let Some(listeners) = &self.tcp_listeners {
let mut listeners = listeners.lock().unwrap();
if listeners.contains_key(&address) {
return Err(io::Error::new(io::ErrorKind::AddrInUse, address.to_string()).into());
}
let (sender, receiver) = tokio::sync::mpsc::unbounded_channel();
listeners.insert(address, sender);
Some(tokio::sync::Mutex::new(receiver))
} else {
None
};
self.active_tcp_listeners.fetch_add(1, Ordering::Relaxed);
Ok(Arc::new(TestTcpListener {
address,
active_listeners: self.active_tcp_listeners.clone(),
listeners: self.tcp_listeners.clone(),
incoming,
}))
}
}
@@ -8,6 +8,7 @@ use crate::config::{peers::PeerRuntimeSnapshot, runtime::CoreRuntimeConfig};
use super::CoreInstanceConfig;
const DHCP_IPV4_AVAILABLE: bool = cfg!(feature = "dhcp-ipv4");
const DHCP_IPV6_AVAILABLE: bool = cfg!(feature = "dhcp-ipv6");
const SMOLTCP_GATEWAY_AVAILABLE: bool = cfg!(feature = "proxy-smoltcp-stack");
const PACKET_PROXY_AVAILABLE: bool = cfg!(feature = "proxy-packet");
const PROXY_CIDR_MONITOR_AVAILABLE: bool = cfg!(feature = "proxy-cidr-monitor");
@@ -28,6 +29,7 @@ fn validate_snapshot(
) -> anyhow::Result<()> {
let core = &peer.runtime.core;
require(DHCP_IPV4_AVAILABLE, runtime.dhcp_ipv4, "DHCP IPv4")?;
require(DHCP_IPV6_AVAILABLE, runtime.dhcp_ipv6, "DHCP IPv6")?;
require(
SMOLTCP_GATEWAY_AVAILABLE,
runtime.gateway.socks5_bind.is_some() || !runtime.gateway.port_forwards.is_empty(),
+35 -8
View File
@@ -210,14 +210,20 @@ impl CoreInstanceConfig {
network_name: network_name.clone(),
},
routes: RouteConfig {
ipv4: config.get_ipv4().map(|value| IpPrefix {
address: value.address().into(),
prefix_len: value.network_length(),
}),
ipv6: config.get_ipv6().map(|value| IpPrefix {
address: value.address().into(),
prefix_len: value.network_length(),
}),
ipv4: config
.get_ipv4()
.filter(|_| !config.get_dhcp())
.map(|value| IpPrefix {
address: value.address().into(),
prefix_len: value.network_length(),
}),
ipv6: config
.get_ipv6()
.filter(|_| !config.get_dhcpv6())
.map(|value| IpPrefix {
address: value.address().into(),
prefix_len: value.network_length(),
}),
proxy_networks: proxy_networks
.into_iter()
.map(|proxy| ProxyNetworkConfig {
@@ -296,6 +302,9 @@ impl CoreInstanceConfig {
whitelist_priority: None,
},
dhcp_ipv4: config.get_dhcp(),
dhcp_ipv4_cidr: config.get_dhcp_config().cidr(),
dhcp_ipv6: config.get_dhcpv6(),
dhcp_ipv6_cidr: config.get_dhcpv6_config().cidr(),
gateway: GatewayRuntimeConfig {
socks5_bind,
port_forwards: if host.ignore_unsupported_config && !host.gateway_enabled {
@@ -407,6 +416,24 @@ mod tests {
use super::*;
#[test]
fn dhcp_initial_routes_ignore_static_addresses_of_managed_families() {
for dhcp_ipv4 in [false, true] {
for dhcp_ipv6 in [false, true] {
let config =
TomlConfig::new_from_str("ipv4 = '10.20.0.7/24'\nipv6 = 'fd12::7/64'").unwrap();
config.set_dhcp(dhcp_ipv4);
config.set_dhcpv6(dhcp_ipv6);
let normalized = CoreInstanceConfig::from_toml(&config).unwrap();
let routes = &normalized.peer.snapshot.runtime.core.routes;
assert_eq!(routes.ipv4.is_some(), !dhcp_ipv4);
assert_eq!(routes.ipv6.is_some(), !dhcp_ipv6);
assert_eq!(config.get_ipv4().unwrap(), "10.20.0.7/24".parse().unwrap());
assert_eq!(config.get_ipv6().unwrap(), "fd12::7/64".parse().unwrap());
}
}
}
#[test]
fn tcp_stun_servers_follow_toml_override_rules() {
let fallback = TomlConfig::new_from_str(
+47 -15
View File
@@ -6,8 +6,8 @@
use std::sync::{Arc, Weak};
#[cfg(feature = "dhcp-ipv4")]
use crate::gateway::dhcp::{DhcpIpv4Host, DhcpIpv4RouteSource};
#[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))]
use crate::gateway::dhcp::{DhcpHost, DhcpRouteSource};
use super::{CoreInstance, CoreInstanceHost, CoreInstanceState};
@@ -66,16 +66,16 @@ where
}
}
#[cfg(feature = "dhcp-ipv4")]
async fn start_dhcp_ipv4(&self, host: Option<Arc<dyn DhcpIpv4Host>>) -> anyhow::Result<()> {
if !self.runtime_config.snapshot().services.dhcp_ipv4 {
#[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))]
async fn start_dhcp(&self, host: Option<Arc<dyn DhcpHost>>) -> anyhow::Result<()> {
let config = self.runtime_config.snapshot();
if !config.services.dhcp_ipv4 && !config.services.dhcp_ipv6 {
return Ok(());
}
let host = host.ok_or_else(|| {
anyhow::anyhow!("DHCP IPv4 is enabled but no host adapter was provided")
})?;
let route_source: Arc<dyn DhcpIpv4RouteSource> = self.peer_manager.clone();
self.dhcp_ipv4
let host = host
.ok_or_else(|| anyhow::anyhow!("DHCP is enabled but no host adapter was provided"))?;
let route_source: Arc<dyn DhcpRouteSource> = self.peer_manager.clone();
self.dhcp
.start(route_source, self.runtime_config.clone(), host)
.await;
Ok(())
@@ -125,9 +125,9 @@ where
self.public_ipv6_provider.start().await;
let dhcp_host = self.instance_runtime.prepare(self.packet_plane()).await?;
#[cfg(feature = "dhcp-ipv4")]
self.start_dhcp_ipv4(dhcp_host).await?;
#[cfg(not(feature = "dhcp-ipv4"))]
#[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))]
self.start_dhcp(dhcp_host).await?;
#[cfg(not(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6")))]
let _ = dhcp_host;
#[cfg(feature = "wrapped-transport")]
if let Some(wrapped_transport) = &self.wrapped_transport {
@@ -173,8 +173,8 @@ where
self.vpn_portal.stop().await;
#[cfg(feature = "public-ipv6-provider")]
self.public_ipv6_provider.stop().await;
#[cfg(feature = "dhcp-ipv4")]
self.dhcp_ipv4.stop().await;
#[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))]
self.dhcp.stop().await;
#[cfg(feature = "proxy-cidr-monitor")]
self.proxy_cidr_monitor.stop().await;
if let Some(listener) = &self.listener {
@@ -216,8 +216,25 @@ where
self.packet_egress.stop().await;
}
/// Returns the optional Host-owned executor for this instance.
pub fn runtime_handle(&self) -> Option<tokio::runtime::Handle> {
self.instance_runtime.runtime_handle()
}
/// Starts the complete instance through one serial composition path.
pub async fn start(self: &Arc<Self>) -> anyhow::Result<()> {
if let Some(runtime) = self.runtime_handle() {
let instance = self.clone();
return tokio_util::task::AbortOnDropHandle::new(
runtime.spawn(async move { instance.start_on_runtime().await }),
)
.await
.map_err(|error| anyhow::anyhow!("instance start task failed: {error}"))?;
}
self.start_on_runtime().await
}
async fn start_on_runtime(self: &Arc<Self>) -> anyhow::Result<()> {
let _operation = self.operation.lock().await;
let state = self.state();
if state != CoreInstanceState::Created {
@@ -260,6 +277,21 @@ where
}
pub async fn stop(self: &Arc<Self>) {
self.cancel.cancel();
if let Some(runtime) = self.runtime_handle() {
let instance = self.clone();
// Cleanup must survive cancellation even before its first poll.
// Dropping a plain JoinHandle detaches rather than aborts the task.
runtime
.spawn(async move { instance.stop_on_runtime().await })
.await
.expect("instance stop task failed");
} else {
self.stop_on_runtime().await;
}
}
async fn stop_on_runtime(self: &Arc<Self>) {
self.cancel.cancel();
let mut recovery = self.recovery_guard();
let _operation = self.operation.lock().await;
+8 -4
View File
@@ -387,7 +387,11 @@ where
let instance_state_changes = self.instance_state_changes.clone();
runtime.spawn(async move {
if let Err(error) = instance.start().await {
tracing::error!(%error, %instance_id, "instance failed to start");
tracing::error!(
error = %format_args!("{error:#}"),
%instance_id,
"instance failed to start"
);
}
instance_state_changes.mark_changed();
notifier.notify_one();
@@ -479,9 +483,9 @@ where
}
pub fn data_plane_runtime_handle(&self, instance_id: &Uuid) -> Option<tokio::runtime::Handle> {
self.instance(*instance_id)?;
self.runtime_handle
.clone()
self.instance(*instance_id)?
.runtime_handle()
.or_else(|| self.runtime_handle.clone())
.or_else(|| tokio::runtime::Handle::try_current().ok())
}
+59 -21
View File
@@ -58,7 +58,7 @@ use crate::{
},
},
events::CoreEventSink,
gateway::dhcp::DhcpIpv4Host,
gateway::dhcp::DhcpHost,
host::{
dns::{DnsRecordResolver, DnsResolver},
packet::{HostPacketReceiver, PacketSink, host_packet_channel},
@@ -96,8 +96,8 @@ use crate::gateway::vpn_portal::PortalRuntimeConfig;
#[cfg(feature = "public-ipv6-provider")]
use crate::peers::public_ipv6::provider::PublicIpv6ProviderPlatform;
#[cfg(feature = "dhcp-ipv4")]
use crate::gateway::dhcp::DhcpIpv4Runtime;
#[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))]
use crate::gateway::dhcp::DhcpRuntime;
#[cfg(feature = "proxy-cidr-monitor")]
use crate::gateway::proxy::cidr_monitor::ProxyCidrMonitorRuntime;
#[cfg(feature = "proxy-packet")]
@@ -229,10 +229,16 @@ fn proxy_cidr_snapshot(config: &CoreInstanceRuntimeConfig) -> ProxyCidrSnapshot
/// lifetime, such as a native packet interface.
#[async_trait::async_trait]
pub trait InstanceRuntimeHost: std::any::Any + Send + Sync + 'static {
/// Optional Host-owned executor used for construction, lifecycle, and data-plane tasks.
/// Hosts without a dedicated executor continue to use the caller's runtime.
fn runtime_handle(&self) -> Option<tokio::runtime::Handle> {
None
}
async fn prepare(
&self,
packet_plane: Arc<CorePacketPlane>,
) -> anyhow::Result<Option<Arc<dyn DhcpIpv4Host>>>;
) -> anyhow::Result<Option<Arc<dyn DhcpHost>>>;
async fn shutdown(&self);
@@ -270,7 +276,7 @@ impl InstanceRuntimeHost for () {
async fn prepare(
&self,
_packet_plane: Arc<CorePacketPlane>,
) -> anyhow::Result<Option<Arc<dyn DhcpIpv4Host>>> {
) -> anyhow::Result<Option<Arc<dyn DhcpHost>>> {
Ok(None)
}
@@ -429,8 +435,8 @@ where
packet_proxy: Arc<CoreProxyModule<H>>,
#[cfg(feature = "proxy-cidr-monitor")]
proxy_cidr_monitor: ProxyCidrMonitorRuntime,
#[cfg(feature = "dhcp-ipv4")]
dhcp_ipv4: DhcpIpv4Runtime,
#[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))]
dhcp: DhcpRuntime,
pub(super) packet_egress: Arc<dyn PacketEgressHost>,
pub(super) packet_receiver: Mutex<Option<HostPacketReceiver>>,
pub(super) peer_center: Arc<PeerCenterInstance>,
@@ -783,6 +789,7 @@ where
host.clone(),
direct_options.tcp_bind.context.clone(),
events.clone(),
instance_runtime.runtime_handle(),
);
#[cfg(feature = "tcp-hole-punch")]
let tcp_hole_punch = stun
@@ -877,8 +884,8 @@ where
packet_proxy,
#[cfg(feature = "proxy-cidr-monitor")]
proxy_cidr_monitor,
#[cfg(feature = "dhcp-ipv4")]
dhcp_ipv4: DhcpIpv4Runtime::new(),
#[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))]
dhcp: DhcpRuntime::new(),
packet_egress,
packet_receiver: Mutex::new(Some(packet_rx)),
peer_center,
@@ -929,26 +936,57 @@ where
anyhow::bail!("runtime config cannot update while instance is stopping or stopped");
}
self.validate_runtime_config_capabilities(&config)?;
#[cfg(feature = "proxy-smoltcp-stack")]
let prepared = self.prepare_port_forwards(&config).await?;
self.commit_runtime_config_under_operation(
config,
#[cfg(feature = "proxy-smoltcp-stack")]
prepared,
|| (),
)
.await
}
#[cfg(feature = "proxy-smoltcp-stack")]
pub(crate) async fn prepare_port_forwards(
&self,
config: &CoreInstanceRuntimeConfig,
) -> anyhow::Result<Option<crate::gateway::PreparedPortForwards<H>>> {
self.port_forward_adapter
.prepare(&config.services.gateway.port_forwards)
.await
}
pub(crate) async fn commit_runtime_config_under_operation(
&self,
config: CoreInstanceRuntimeConfig,
#[cfg(feature = "proxy-smoltcp-stack")] prepared: Option<
crate::gateway::PreparedPortForwards<H>,
>,
commit_config: impl FnOnce(),
) -> anyhow::Result<()> {
#[cfg(feature = "test-utils")]
let reload_acl = self.runtime_config.snapshot().services.acl != config.services.acl;
let published = self.peer_manager.update_runtime_config(config).await?;
let (published, cleanup) = self
.peer_manager
.update_runtime_config_with(config, || {
#[cfg(feature = "proxy-smoltcp-stack")]
let cleanup = prepared.map(|prepared| prepared.commit());
#[cfg(not(feature = "proxy-smoltcp-stack"))]
let cleanup: Option<tokio::task::JoinHandle<()>> = None;
commit_config();
cleanup
})
.await?;
#[cfg(feature = "test-utils")]
if reload_acl {
self.acl_reload_count.fetch_add(1, Ordering::Relaxed);
}
self.proxy_cidr_table
.update_snapshot(proxy_cidr_snapshot(&published));
#[cfg(feature = "proxy-smoltcp-stack")]
self.port_forward_adapter
.reload(
&self
.runtime_config
.snapshot()
.services
.gateway
.port_forwards,
)
.await?;
if let Some(cleanup) = cleanup {
let _ = cleanup.await;
}
Ok(())
}
+556 -5
View File
@@ -628,6 +628,57 @@ mod portable_runtime {
);
}
#[cfg(feature = "dhcp-ipv6")]
#[tokio::test]
async fn runtime_update_preserves_dhcp_owned_ipv6() {
let mut initial = test_config("dhcp-runtime-update");
initial.connectivity.runtime.dhcp_ipv6 = true;
let instance = build_instance(initial).unwrap();
let lease = IpPrefix {
address: "fd12::7".parse().unwrap(),
prefix_len: 64,
};
instance.runtime_config.update_peer_with(|peer| {
peer.runtime.core.routes.ipv6 = Some(lease.clone());
});
let mut replacement = test_config("dhcp-runtime-update");
replacement.connectivity.runtime.dhcp_ipv6 = true;
instance
.update_runtime_config(runtime_snapshot(&replacement))
.await
.unwrap();
assert_eq!(
instance
.runtime_config
.snapshot()
.peer
.runtime
.core
.routes
.ipv6,
Some(lease)
);
let static_replacement = test_config("dhcp-runtime-update");
instance
.update_runtime_config(runtime_snapshot(&static_replacement))
.await
.unwrap();
assert_eq!(
instance
.runtime_config
.snapshot()
.peer
.runtime
.core
.routes
.ipv6,
None
);
}
#[tokio::test]
async fn core_instance_is_a_direct_managed_record() {
let instance = build_instance(test_config("managed-directly")).unwrap();
@@ -833,6 +884,88 @@ network_secret = "network-secret"
assert!(error.to_string().contains("instance is not ready"));
}
#[cfg(feature = "management")]
#[tokio::test]
async fn manual_route_patch_preserves_empty_mode_without_restart() {
use easytier_proto::api::config::{ConfigPatchAction, InstanceConfigPatch, RoutePatch};
let (packet_sink, _packet_receiver) = tokio::sync::mpsc::channel(16);
let config = TomlConfig::new_from_str(
"[network_identity]\nnetwork_name = 'manual-routes'\nnetwork_secret = 'secret'",
)
.unwrap();
let instance =
CoreInstance::from_toml(config, adapters(None, Arc::new(packet_sink))).unwrap();
instance.start().await.unwrap();
let peer_id = instance.peer_id();
let persistence = RecordingConfigPatchPersistence {
writes: std::sync::Mutex::new(Vec::new()),
fail: AtomicBool::new(true),
};
let route: cidr::Ipv4Cidr = "10.20.0.0/24".parse().unwrap();
let add = RoutePatch {
action: ConfigPatchAction::Add as i32,
cidr: Some("10.20.0.0/24".parse::<cidr::Ipv4Inet>().unwrap().into()),
};
let clear = RoutePatch {
action: ConfigPatchAction::Clear as i32,
..Default::default()
};
let failed = crate::management::apply_config_patch(
&instance,
InstanceConfigPatch {
enable_manual_routes: Some(true),
routes: vec![add],
..Default::default()
},
Some(&persistence),
)
.await;
assert!(failed.is_err());
assert_eq!(instance.toml_config().unwrap().get_routes(), None);
assert_eq!(
instance.runtime_config.snapshot().services.manual_routes,
None
);
assert!(persistence.writes.lock().unwrap().is_empty());
persistence.fail.store(false, Ordering::Relaxed);
for (mode, patches, expected) in [
(Some(true), Vec::new(), Some(Vec::new())),
(Some(true), vec![add], Some(vec![route])),
(Some(true), vec![clear], Some(Vec::new())),
(Some(false), Vec::new(), None),
// Released callers that omit the new mode keep their old behavior.
(None, vec![add], Some(vec![route])),
(None, vec![clear], None),
] {
crate::management::apply_config_patch(
&instance,
InstanceConfigPatch {
enable_manual_routes: mode,
routes: patches,
..Default::default()
},
Some(&persistence),
)
.await
.unwrap();
assert_eq!(instance.toml_config().unwrap().get_routes(), expected);
assert_eq!(
instance.runtime_config.snapshot().services.manual_routes,
expected
.as_ref()
.map(|routes| routes.iter().copied().collect())
);
let writes = persistence.writes.lock().unwrap();
let persisted = TomlConfig::new_from_str(writes.last().unwrap()).unwrap();
assert_eq!(persisted.get_routes(), expected);
assert_eq!(instance.peer_id(), peer_id);
assert_eq!(instance.state(), CoreInstanceState::Running);
}
instance.stop().await;
}
#[cfg(feature = "management")]
#[tokio::test]
async fn managed_credential_patch_is_durable_atomic_and_does_not_restart() {
@@ -1332,7 +1465,7 @@ virtual_ip = "10.82.0.2/24"
async fn prepare(
&self,
_packet_plane: Arc<CorePacketPlane>,
) -> anyhow::Result<Option<Arc<dyn DhcpIpv4Host>>> {
) -> anyhow::Result<Option<Arc<dyn DhcpHost>>> {
Ok(None)
}
@@ -1366,7 +1499,7 @@ virtual_ip = "10.82.0.2/24"
async fn prepare(
&self,
_packet_plane: Arc<CorePacketPlane>,
) -> anyhow::Result<Option<Arc<dyn DhcpIpv4Host>>> {
) -> anyhow::Result<Option<Arc<dyn DhcpHost>>> {
anyhow::bail!("host prepare failed")
}
@@ -1477,7 +1610,7 @@ virtual_ip = "10.82.0.2/24"
async fn prepare(
&self,
_packet_plane: Arc<CorePacketPlane>,
) -> anyhow::Result<Option<Arc<dyn DhcpIpv4Host>>> {
) -> anyhow::Result<Option<Arc<dyn DhcpHost>>> {
self.prepare_started.notify_one();
self.prepare_release.notified().await;
Ok(None)
@@ -1513,6 +1646,162 @@ virtual_ip = "10.82.0.2/24"
assert_eq!(runtime_host.shutdown_calls.load(Ordering::Relaxed), 1);
}
#[test]
fn host_executor_routes_lifecycle_and_cancelled_start() {
struct ExecutorRuntimeHost {
handle: tokio::runtime::Handle,
block_prepare: bool,
prepare_started: Notify,
shutdown_calls: AtomicUsize,
}
#[async_trait]
impl InstanceRuntimeHost for ExecutorRuntimeHost {
fn runtime_handle(&self) -> Option<tokio::runtime::Handle> {
Some(self.handle.clone())
}
async fn prepare(
&self,
_packet_plane: Arc<CorePacketPlane>,
) -> anyhow::Result<Option<Arc<dyn DhcpHost>>> {
assert_eq!(tokio::runtime::Handle::current().id(), self.handle.id());
self.prepare_started.notify_one();
if self.block_prepare {
std::future::pending::<()>().await;
}
Ok(None)
}
async fn shutdown(&self) {
assert_eq!(tokio::runtime::Handle::current().id(), self.handle.id());
self.shutdown_calls.fetch_add(1, Ordering::Relaxed);
}
}
for block_prepare in [false, true] {
let executor = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.unwrap();
let caller = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let runtime_host = Arc::new(ExecutorRuntimeHost {
handle: executor.handle().clone(),
block_prepare,
prepare_started: Notify::new(),
shutdown_calls: AtomicUsize::new(0),
});
let instance = {
let _runtime = executor.enter();
let (packet_sink, _packet_receiver) = tokio::sync::mpsc::channel(16);
let mut adapters = adapters(None, Arc::new(packet_sink));
adapters.instance_runtime = runtime_host.clone();
CoreInstance::new(test_config("host-executor"), adapters).unwrap()
};
caller.block_on(async {
if block_prepare {
let start = tokio::spawn({
let instance = instance.clone();
async move { instance.start().await }
});
tokio::time::timeout(
Duration::from_secs(2),
runtime_host.prepare_started.notified(),
)
.await
.unwrap();
start.abort();
assert!(start.await.unwrap_err().is_cancelled());
} else {
instance.start().await.unwrap();
assert_eq!(instance.state(), CoreInstanceState::Running);
instance.stop().await;
}
tokio::time::timeout(Duration::from_secs(2), async {
while instance.state() != CoreInstanceState::Stopped {
tokio::time::sleep(Duration::from_millis(1)).await;
}
})
.await
.expect("lifecycle cleanup must complete on the Host executor");
assert_eq!(runtime_host.shutdown_calls.load(Ordering::Relaxed), 1);
});
}
}
#[test]
fn cancelled_stop_before_executor_poll_still_finishes_cleanup() {
struct ExecutorRuntimeHost {
handle: tokio::runtime::Handle,
shutdown_calls: AtomicUsize,
}
#[async_trait]
impl InstanceRuntimeHost for ExecutorRuntimeHost {
fn runtime_handle(&self) -> Option<tokio::runtime::Handle> {
Some(self.handle.clone())
}
async fn prepare(
&self,
_packet_plane: Arc<CorePacketPlane>,
) -> anyhow::Result<Option<Arc<dyn DhcpHost>>> {
Ok(None)
}
async fn shutdown(&self) {
assert_eq!(tokio::runtime::Handle::current().id(), self.handle.id());
self.shutdown_calls.fetch_add(1, Ordering::Relaxed);
}
}
let executor = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let runtime_host = Arc::new(ExecutorRuntimeHost {
handle: executor.handle().clone(),
shutdown_calls: AtomicUsize::new(0),
});
let instance = {
let _runtime = executor.enter();
let (packet_sink, _packet_receiver) = tokio::sync::mpsc::channel(16);
let mut adapters = adapters(None, Arc::new(packet_sink));
adapters.instance_runtime = runtime_host.clone();
CoreInstance::new(test_config("cancel-stop-before-poll"), adapters).unwrap()
};
executor.block_on(instance.start()).unwrap();
assert_eq!(instance.state(), CoreInstanceState::Running);
let caller = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
caller.block_on(async {
// The instance executor is not driven here, so the dispatched stop
// cannot be polled before its caller is cancelled.
let mut stop = Box::pin(instance.stop());
assert!(futures::poll!(&mut stop).is_pending());
drop(stop);
});
drop(caller);
executor.block_on(async {
tokio::time::timeout(Duration::from_secs(2), async {
while instance.state() != CoreInstanceState::Stopped {
tokio::time::sleep(Duration::from_millis(1)).await;
}
})
.await
.expect("cancelled stop must still finish on the instance executor");
});
assert_eq!(runtime_host.shutdown_calls.load(Ordering::Relaxed), 1);
}
#[cfg(feature = "management")]
#[tokio::test]
async fn cancelled_delete_finishes_stop_and_keeps_wait_blocked() {
@@ -1542,7 +1831,7 @@ virtual_ip = "10.82.0.2/24"
async fn prepare(
&self,
_packet_plane: Arc<CorePacketPlane>,
) -> anyhow::Result<Option<Arc<dyn DhcpIpv4Host>>> {
) -> anyhow::Result<Option<Arc<dyn DhcpHost>>> {
Ok(None)
}
@@ -1832,7 +2121,7 @@ virtual_ip = "10.82.0.2/24"
async fn prepare(
&self,
_packet_plane: Arc<CorePacketPlane>,
) -> anyhow::Result<Option<Arc<dyn DhcpIpv4Host>>> {
) -> anyhow::Result<Option<Arc<dyn DhcpHost>>> {
Ok(None)
}
@@ -2692,6 +2981,268 @@ virtual_ip = "10.82.0.2/24"
assert_eq!(host.active_tcp_listeners.load(Ordering::Relaxed), 0);
}
#[cfg(all(feature = "management", feature = "proxy-smoltcp-stack"))]
mod port_forward_reload {
use super::*;
use easytier_proto::api::config::{
ConfigPatchAction, InstanceConfigPatch, PortForwardPatch,
};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::mpsc::UnboundedReceiver;
fn forward(bind_port: u16, destination_port: u16) -> PortForwardConfig {
PortForwardConfig {
bind_addr: SocketAddr::from(([127, 0, 0, 1], bind_port)),
dst_addr: SocketAddr::from(([10, 144, 0, 2], destination_port)),
proto: "tcp".to_owned(),
}
}
async fn start() -> (
Arc<CoreInstance<TestHost>>,
Arc<TestHost>,
UnboundedReceiver<(SocketAddr, tokio::io::DuplexStream)>,
) {
let (connections, receiver) = tokio::sync::mpsc::unbounded_channel();
let host = Arc::new(TestHost {
port_forward_connections: Some(connections),
tcp_listeners: Some(Arc::new(std::sync::Mutex::new(Default::default()))),
..Default::default()
});
let config =
TomlConfig::new_from_str("instance_name = \"port-forward-reload\"").unwrap();
config.set_port_forwards(vec![forward(19491, 19381)]);
let (packet_sink, _packet_receiver) = tokio::sync::mpsc::channel(16);
let instance = CoreInstance::from_toml(
config,
adapters_with_host(host.clone(), None, Arc::new(packet_sink)),
)
.unwrap();
instance.start().await.unwrap();
(instance, host, receiver)
}
async fn assert_destination(
host: &TestHost,
receiver: &mut UnboundedReceiver<(SocketAddr, tokio::io::DuplexStream)>,
config: &PortForwardConfig,
) -> (tokio::io::DuplexStream, tokio::io::DuplexStream) {
let mut incoming = host.connect_to_listener(config.bind_addr);
let outgoing = tokio::time::timeout(Duration::from_secs(2), async {
let (destination, mut outgoing) = receiver.recv().await.unwrap();
assert_eq!(destination, config.dst_addr);
incoming.write_all(b"request").await.unwrap();
let mut data = [0; 7];
outgoing.read_exact(&mut data).await.unwrap();
assert_eq!(&data, b"request");
outgoing
})
.await
.expect("port forward must accept and forward to its committed destination");
(incoming, outgoing)
}
fn patch(
removed: &[PortForwardConfig],
added: &[PortForwardConfig],
) -> InstanceConfigPatch {
InstanceConfigPatch {
port_forwards: removed
.iter()
.map(|config| (ConfigPatchAction::Remove, config))
.chain(added.iter().map(|config| (ConfigPatchAction::Add, config)))
.map(|(action, config)| PortForwardPatch {
action: action as i32,
cfg: Some(config.clone().into()),
})
.collect(),
..Default::default()
}
}
fn persistence(fail: bool) -> RecordingConfigPatchPersistence {
RecordingConfigPatchPersistence {
writes: std::sync::Mutex::new(Vec::new()),
fail: AtomicBool::new(fail),
}
}
#[tokio::test]
async fn destination_changes_reuse_listener() {
let (instance, host, mut connections) = start().await;
let (mut established_client, mut established_destination) =
assert_destination(&host, &mut connections, &forward(19491, 19381)).await;
let initial_binds = host.tcp_binds.load(Ordering::Relaxed);
for port in [19382, 19383, 19381] {
let config = forward(19491, port);
let mut runtime = (*instance.runtime_config.snapshot()).clone();
runtime.services.gateway.port_forwards = vec![config.clone()];
instance.update_runtime_config(runtime).await.unwrap();
assert_eq!(host.tcp_binds.load(Ordering::Relaxed), initial_binds);
assert_eq!(host.active_tcp_listeners.load(Ordering::Relaxed), 1);
assert_destination(&host, &mut connections, &config).await;
tokio::time::timeout(Duration::from_secs(2), async {
let payload = port.to_be_bytes();
established_client.write_all(&payload).await.unwrap();
let mut received = [0; 2];
established_destination
.read_exact(&mut received)
.await
.unwrap();
assert_eq!(received, payload);
established_destination.write_all(&payload).await.unwrap();
established_client.read_exact(&mut received).await.unwrap();
assert_eq!(received, payload);
})
.await
.expect("changing the destination must preserve established TCP connections");
}
instance.stop().await;
}
#[tokio::test]
async fn failed_runtime_bind_keeps_committed_snapshot() {
let (instance, host, mut connections) = start().await;
let original = forward(19491, 19381);
let rejected = forward(19492, 19382);
*host.reject_tcp_bind.lock().unwrap() = Some(rejected.bind_addr);
let mut runtime = (*instance.runtime_config.snapshot()).clone();
runtime.services.gateway.port_forwards = vec![original.clone(), rejected];
assert!(
instance
.update_runtime_config(runtime.clone())
.await
.is_err()
);
assert_eq!(
instance
.runtime_config
.snapshot()
.services
.gateway
.port_forwards,
std::slice::from_ref(&original)
);
assert_eq!(host.active_tcp_listeners.load(Ordering::Relaxed), 1);
assert_destination(&host, &mut connections, &original).await;
*host.reject_tcp_bind.lock().unwrap() = None;
instance.update_runtime_config(runtime).await.unwrap();
assert_eq!(host.active_tcp_listeners.load(Ordering::Relaxed), 2);
instance.stop().await;
}
#[tokio::test]
async fn failed_patch_bind_releases_prepared_listener_and_retries() {
let (instance, host, mut connections) = start().await;
let original = forward(19491, 19381);
let added = [forward(19492, 19382), forward(19493, 19383)];
let before = instance.toml_config().unwrap().dump();
let persistence = persistence(false);
let patch = patch(&[], &added);
*host.reject_tcp_bind.lock().unwrap() = Some(added[1].bind_addr);
let initial_binds = host.tcp_binds.load(Ordering::Relaxed);
let error =
crate::management::apply_config_patch(&instance, patch.clone(), Some(&persistence))
.await
.unwrap_err();
assert!(error.to_string().contains("test host rejected TCP bind"));
assert_eq!(host.tcp_binds.load(Ordering::Relaxed), initial_binds + 2);
assert_eq!(host.active_tcp_listeners.load(Ordering::Relaxed), 1);
assert_eq!(instance.toml_config().unwrap().dump(), before);
assert_eq!(
instance
.runtime_config
.snapshot()
.services
.gateway
.port_forwards,
std::slice::from_ref(&original)
);
assert!(persistence.writes.lock().unwrap().is_empty());
assert_destination(&host, &mut connections, &original).await;
*host.reject_tcp_bind.lock().unwrap() = None;
crate::management::apply_config_patch(&instance, patch, Some(&persistence))
.await
.unwrap();
assert_eq!(host.active_tcp_listeners.load(Ordering::Relaxed), 3);
assert_eq!(persistence.writes.lock().unwrap().len(), 1);
for config in &added {
assert_destination(&host, &mut connections, config).await;
}
instance.stop().await;
}
#[tokio::test]
async fn failed_patch_persistence_keeps_destination_and_releases_prepared_listener() {
let (instance, host, mut connections) = start().await;
let original = forward(19491, 19381);
let replacement = forward(19491, 19382);
let added = forward(19492, 19383);
let before = instance.toml_config().unwrap().dump();
let persistence = persistence(true);
let patch = patch(
std::slice::from_ref(&original),
&[replacement.clone(), added.clone()],
);
let error =
crate::management::apply_config_patch(&instance, patch.clone(), Some(&persistence))
.await
.unwrap_err();
assert!(
error
.to_string()
.contains("injected config persistence failure")
);
assert_eq!(host.active_tcp_listeners.load(Ordering::Relaxed), 1);
assert_eq!(instance.toml_config().unwrap().dump(), before);
assert_eq!(
instance
.runtime_config
.snapshot()
.services
.gateway
.port_forwards,
std::slice::from_ref(&original)
);
assert!(persistence.writes.lock().unwrap().is_empty());
assert_destination(&host, &mut connections, &original).await;
persistence.fail.store(false, Ordering::Relaxed);
crate::management::apply_config_patch(&instance, patch, Some(&persistence))
.await
.unwrap();
assert_eq!(host.active_tcp_listeners.load(Ordering::Relaxed), 2);
assert_eq!(persistence.writes.lock().unwrap().len(), 1);
assert_destination(&host, &mut connections, &replacement).await;
assert_destination(&host, &mut connections, &added).await;
instance.stop().await;
}
#[tokio::test]
async fn removed_listener_can_be_rebound_immediately() {
let (instance, host, mut connections) = start().await;
let original = (*instance.runtime_config.snapshot()).clone();
let mut empty = original.clone();
empty.services.gateway.port_forwards.clear();
instance.update_runtime_config(empty).await.unwrap();
assert_eq!(host.active_tcp_listeners.load(Ordering::Relaxed), 0);
instance.update_runtime_config(original).await.unwrap();
assert_eq!(host.active_tcp_listeners.load(Ordering::Relaxed), 1);
assert_destination(&host, &mut connections, &forward(19491, 19381)).await;
instance.stop().await;
}
}
#[cfg(feature = "proxy-packet")]
#[tokio::test]
async fn runtime_core_instance_owns_wrapped_transport_source_nat() {
+7 -5
View File
@@ -342,10 +342,11 @@ where
Accepted: Send + 'static,
{
let mut listener = creator();
match listener.listen().await {
Ok(()) => Ok(listener),
Err(error) => Err(error),
}
listener
.listen()
.await
.with_context(|| format!("failed to listen on {}", listener.local_url()))?;
Ok(listener)
}
async fn run_listener<Accepted, H>(
@@ -782,7 +783,8 @@ mod tests {
true,
);
assert!(manager.run().await.is_err());
let error = manager.run().await.unwrap_err();
assert!(format!("{error:#}").contains("mock://second"));
crate::foundation::time::sleep(Duration::from_millis(20)).await;
assert!(handler.accepted.lock().unwrap().is_empty());
assert!(
@@ -5,10 +5,11 @@ use easytier_proto::api::config::{
self, AclPatch, ConfigPatchAction, ExitNodePatch, InstanceConfigPatch, Patchable,
PortForwardPatch, ProxyNetworkPatch, RoutePatch, UrlPatch, VpnPortalClientPatch,
};
use easytier_proto::common::FlagsPatch;
use optionize::Optionized as _;
use crate::{
config::{
api_input::managed_credential_from_proto,
peers::AclRuleConfig,
runtime::CoreInstanceRuntimeConfig,
toml::{ConfigLoader as _, TomlConfig},
@@ -50,9 +51,25 @@ where
// Preserve the existing ordered partial-commit contract: earlier valid
// sub-patches remain applied if a later sub-patch fails.
let patch_result: anyhow::Result<(bool, bool)> = async {
let result = patch_port_forwards(&candidate, patch.port_forwards);
validate_persist_and_commit_candidate(instance, &config, &candidate, persistence).await?;
result?;
if !patch.port_forwards.is_empty() {
patch_port_forwards(&candidate, patch.port_forwards)?;
// Port forwards have fallible host resources. Reserve them before the
// durable write, then commit resources and readback together. A failed
// bind or persistence operation must leave the previous rule usable.
let normalized = validate_candidate(instance, &candidate)?;
let runtime = runtime_config_from_normalized(&normalized);
#[cfg(feature = "proxy-smoltcp-stack")]
let prepared = instance.prepare_port_forwards(&runtime).await?;
persist_candidate_if_changed(instance, &config, &candidate, persistence).await?;
instance
.commit_runtime_config_under_operation(
runtime,
#[cfg(feature = "proxy-smoltcp-stack")]
prepared,
|| config.replace_from_snapshot(&candidate),
)
.await?;
}
let result = patch_acl(&candidate, patch.acl);
validate_persist_and_commit_candidate(instance, &config, &candidate, persistence).await?;
@@ -62,7 +79,7 @@ where
validate_persist_and_commit_candidate(instance, &config, &candidate, persistence).await?;
result?;
let result = patch_routes(&candidate, patch.routes);
let result = patch_routes(&candidate, patch.routes, patch.enable_manual_routes);
validate_persist_and_commit_candidate(instance, &config, &candidate, persistence).await?;
result?;
@@ -90,19 +107,16 @@ where
{
candidate.set_ipv4(Some(ipv4.into()));
}
if let Some(ipv6) = patch.ipv6 {
if let Some(ipv6) = patch.ipv6
&& !candidate.get_dhcpv6()
{
candidate.set_ipv6(Some(ipv6.into()));
}
if let Some(disable_relay_data) = patch.disable_relay_data {
let mut flags = candidate.get_flags();
flags.disable_relay_data = disable_relay_data;
candidate.set_flags(flags);
}
if let Some(prefer_peer_relay) = patch.prefer_peer_relay {
let mut flags = candidate.get_flags();
flags.prefer_peer_relay = prefer_peer_relay;
candidate.set_flags(flags);
}
candidate.patch_flags(FlagsPatch {
disable_relay_data: patch.disable_relay_data,
prefer_peer_relay: patch.prefer_peer_relay,
..Default::default()
});
if let Some(enabled) = patch.ipv6_public_addr_provider {
candidate.set_ipv6_public_addr_provider(enabled);
provider_config_changed = true;
@@ -188,8 +202,8 @@ where
let entries = managed
.entries
.iter()
.map(managed_credential_from_proto)
.collect::<Vec<_>>();
.map(|credential| credential.clone().upgrade())
.collect::<Result<Vec<_>, _>>()?;
let replacement = credential_manager
.validate_managed_credentials(&entries)
.map_err(anyhow::Error::msg)?;
@@ -457,15 +471,25 @@ fn patch_proxy_networks(
Ok(())
}
fn patch_routes(config: &TomlConfig, patches: Vec<RoutePatch>) -> anyhow::Result<()> {
if patches.is_empty() {
fn patch_routes(
config: &TomlConfig,
patches: Vec<RoutePatch>,
enable_manual_routes: Option<bool>,
) -> anyhow::Result<()> {
if patches.is_empty() && enable_manual_routes.is_none() {
return Ok(());
}
let mut current = config.get_routes().unwrap_or_default();
let patches = patches.into_iter().map(Into::into).collect::<Vec<_>>();
trace_patchables(&patches);
config::patch_vec(&mut current, patches);
config.set_routes((!current.is_empty()).then_some(current));
// Mode and list form one configuration update. An explicit mode supports
// empty manual routes; callers omitting it retain the released semantics.
config.set_routes(
enable_manual_routes
.unwrap_or(!current.is_empty())
.then_some(current),
);
Ok(())
}
@@ -60,6 +60,7 @@ where
dev_name,
my_node_info: Some(MyNodeInfo {
virtual_ipv4: node.ipv4_addr.map(Into::into),
virtual_ipv6: node.ipv6_addr.map(Into::into),
hostname: node.hostname,
version: node.version,
ips: Some(node.ip_list),
@@ -435,6 +435,7 @@ fn build_heartbeat_request(
.map(Into::into)
.collect(),
support_heartbeat_policy: true,
support_manual_routes_patch: true,
}
}
@@ -748,6 +749,8 @@ mod tests {
vec![failed]
);
assert!(request.support_heartbeat_policy);
assert!(request.support_manual_routes_patch);
assert!(!HeartbeatRequest::default().support_manual_routes_patch);
}
#[test]
+174 -2
View File
@@ -199,7 +199,7 @@ impl AttachedPeerRuntime {
let public_ipv6_runtime =
CorePublicIpv6Runtime::new(runtime_config.clone(), Arc::new(()), Arc::new(()));
let flags = peer_snapshot.flags.clone();
let peer_manager = Arc::new(PeerManagerCore::new(
let mut peer_manager = PeerManagerCore::new(
PortablePeerManagerConfig {
snapshot: peer_snapshot,
route_algo: RouteAlgoType::Ospf,
@@ -214,7 +214,9 @@ impl AttachedPeerRuntime {
Arc::new(()),
None,
Arc::new(()),
)?);
)?;
peer_manager.inherit_exit_nodes(&network_peer_manager);
let peer_manager = Arc::new(peer_manager);
if let Err(error) = peer_manager
.follow_network_policy(network_runtime_config, config.groups)
.await
@@ -621,6 +623,176 @@ mod tests {
peer_manager_with_acl(Vec::new())
}
async fn connect_exit_peer(
network: &PeerManagerCore,
store: &CoreRuntimeConfigStore,
address: Ipv4Addr,
) -> (Arc<PeerManagerCore>, HostPacketReceiver) {
let mut snapshot = store.snapshot().peer.as_ref().clone();
snapshot.runtime.core.routes.ipv4 = Some(IpPrefix {
address: address.into(),
prefix_len: 24,
});
if let Some(secure) = snapshot.runtime.secure_mode.as_mut() {
let private = StaticSecret::from([address.octets()[3]; 32]);
secure.local_private_key = Some(BASE64_STANDARD.encode(private.as_bytes()));
secure.local_public_key =
Some(BASE64_STANDARD.encode(PublicKey::from(&private).as_bytes()));
}
let flags = snapshot.flags.clone();
let store =
CoreRuntimeConfigStore::new(CoreRuntimeConfig::default(), Arc::new(snapshot.clone()));
let (sender, receiver) = host_packet_channel();
let public_ipv6 = CorePublicIpv6Runtime::new(store.clone(), Arc::new(()), Arc::new(()));
let peer = Arc::new(
PeerManagerCore::new(
PortablePeerManagerConfig {
snapshot,
route_algo: RouteAlgoType::Ospf,
exit_nodes: Vec::new(),
foreign_context_default_flags: flags,
},
Vec::new(),
store,
Arc::new(()),
sender,
public_ipv6,
Arc::new(()),
None,
Arc::new(()),
)
.unwrap(),
);
peer.run().await.unwrap();
let (server, client) = create_ring_tunnel_pair();
let (server_result, client_result) = tokio::join!(
network.add_tunnel_as_server(server),
peer.add_client_tunnel(client),
);
server_result.unwrap();
client_result.unwrap();
(peer, receiver)
}
async fn check_attached_peer_exit_nodes(secure: bool) {
let (network, store) = peer_manager_with_acl_and_secure(Vec::new(), secure);
network.run().await.unwrap();
let first_ip = Ipv4Addr::new(10, 82, 0, 10);
let second_ip = Ipv4Addr::new(10, 82, 0, 11);
let (first, mut first_packets) = connect_exit_peer(&network, &store, first_ip).await;
let (second, mut second_packets) = connect_exit_peer(&network, &store, second_ip).await;
network.update_exit_nodes(vec![first_ip.into()]).await;
let client_ip = Ipv4Addr::new(10, 82, 0, 2);
let attached = AttachedPeerRuntime::connect(
network.clone(),
store,
AttachedPeerConfig {
name: "exit-node-client".to_owned(),
virtual_ip: attached_ipv4(client_ip),
groups: vec!["ops".to_owned()],
identity_private_key: [2; 32],
},
)
.await
.unwrap();
for (exit, address) in [(&first, first_ip), (&second, second_ip)] {
wait_for_route(&attached.peer_manager, exit.my_peer_id(), address, true).await;
wait_for_route(exit, attached.peer_id(), client_ip, true).await;
}
let public_ip = Ipv4Addr::new(8, 8, 8, 8);
let mut request = vec![0u8; 28];
request[0] = 0x45;
request[2..4].copy_from_slice(&28u16.to_be_bytes());
request[8] = 64;
request[9] = 1;
request[12..16].copy_from_slice(&client_ip.octets());
request[16..20].copy_from_slice(&public_ip.octets());
request[20] = 8;
for (exit, address, packets) in [
(&first, first_ip, &mut first_packets),
(&second, second_ip, &mut second_packets),
] {
if address == second_ip {
network.update_exit_nodes(vec![second_ip.into()]).await;
}
assert_eq!(
attached
.peer_manager
.get_msg_dst_peer(&public_ip.into())
.await,
(vec![exit.my_peer_id()], true),
);
attached.send_packet(&request).await.unwrap();
let packet = tokio::time::timeout(Duration::from_secs(5), packets.recv())
.await
.expect("exit node did not receive the client packet")
.unwrap()
.into_core_packet();
assert_eq!(packet.payload(), request.as_slice());
let header = packet.peer_manager_header().unwrap();
assert!(header.is_exit_node());
assert_eq!(header.from_peer_id.get(), attached.peer_id());
assert_eq!(header.to_peer_id.get(), exit.my_peer_id());
let mut reply = request.clone();
reply[12..16].copy_from_slice(&public_ip.octets());
reply[16..20].copy_from_slice(&client_ip.octets());
reply[20] = 0;
exit.send_msg_by_ip(ZCPacket::new_with_payload(&reply), client_ip.into(), false)
.await
.unwrap();
let received = tokio::time::timeout(Duration::from_secs(5), attached.recv_packet())
.await
.expect("exit reply did not reach the client")
.unwrap();
assert_eq!(received.payload(), reply.as_slice());
}
network.update_exit_nodes(Vec::new()).await;
assert_eq!(
attached
.peer_manager
.get_msg_dst_peer(&public_ip.into())
.await,
(Vec::new(), false),
);
// Explicit mesh routes must still win, even with an exit configured.
network.update_exit_nodes(vec![second_ip.into()]).await;
assert_eq!(
attached
.peer_manager
.get_msg_dst_peer(&first_ip.into())
.await,
(vec![first.my_peer_id()], false),
);
request[16..20].copy_from_slice(&first_ip.octets());
attached.send_packet(&request).await.unwrap();
let packet = tokio::time::timeout(Duration::from_secs(5), first_packets.recv())
.await
.unwrap()
.unwrap()
.into_core_packet();
assert_eq!(packet.payload(), request.as_slice());
assert!(!packet.peer_manager_header().unwrap().is_exit_node());
attached.close().await;
first.clear_resources().await;
second.clear_resources().await;
network.clear_resources().await;
}
#[tokio::test]
async fn attached_peer_inherits_exit_nodes_and_updates() {
check_attached_peer_exit_nodes(false).await;
}
#[tokio::test]
async fn secure_attached_peer_inherits_exit_nodes_and_updates() {
check_attached_peer_exit_nodes(true).await;
}
async fn wait_for_route(
peer: &PeerManagerCore,
attached_peer_id: PeerId,
+3 -2
View File
@@ -1,7 +1,7 @@
use std::sync::Arc;
use arc_swap::ArcSwapOption;
use crossbeam::atomic::AtomicCell;
use crossbeam_utils::atomic::AtomicCell;
use dashmap::{DashMap, DashSet};
use parking_lot::{Mutex, RwLock};
@@ -17,6 +17,7 @@ use crate::peers::{
};
use crate::{
config::PeerId,
foundation::small_map_shard_amount,
packet::ZCPacket,
peers::error::Error,
proto::{core_peer::peer::PeerConnInfo, peer_rpc::PeerIdentityType},
@@ -57,7 +58,7 @@ impl Peer {
packet_recv_chan: PacketRecvChan,
context: ArcPeerContext,
) -> Self {
let conns: ConnMap = Arc::new(DashMap::new());
let conns: ConnMap = Arc::new(DashMap::with_shard_amount(small_map_shard_amount()));
let (close_event_sender, mut close_event_receiver) = mpsc::channel(10);
let shutdown_notifier = Arc::new(tokio::sync::Notify::new());
let peer_identity_type = Arc::new(AtomicCell::new(None));
+1 -1
View File
@@ -1,5 +1,5 @@
use arc_swap::ArcSwapOption;
use crossbeam::atomic::AtomicCell;
use crossbeam_utils::atomic::AtomicCell;
use futures::{StreamExt, TryFutureExt};
use std::{
any::Any,
+2 -1
View File
@@ -11,6 +11,7 @@ use tokio::sync::RwLock;
use crate::{
config::PeerId,
foundation::small_map_shard_amount,
packet::ZCPacket,
peers::{
context::{ArcPeerContext, NetworkIdentity, PeerEvent},
@@ -53,7 +54,7 @@ impl PeerMap {
PeerMap {
context,
my_peer_id,
peer_map: DashMap::new(),
peer_map: DashMap::with_shard_amount(small_map_shard_amount()),
packet_send,
routes: RwLock::new(Vec::new()),
alive_client_urls: Arc::new(Mutex::new(HashMap::new())),
+1 -1
View File
@@ -5,7 +5,7 @@ use std::sync::{
use std::time::{Duration, Instant};
use anyhow::anyhow;
use crossbeam::atomic::AtomicCell;
use crossbeam_utils::atomic::AtomicCell;
use dashmap::DashMap;
use crate::peers::util::shrink_dashmap;
+23 -22
View File
@@ -13,10 +13,10 @@ use cidr::{Ipv4Cidr, Ipv4Inet, Ipv6Cidr, Ipv6Inet};
use dashmap::DashMap;
use easytier_proto::{
acl::Acl,
common::{FlagsInConfig, PeerFeatureFlag, SecureModeConfig, StunInfo, TunnelInfo},
common::{Flags, PeerFeatureFlag, SecureModeConfig, StunInfo, TunnelInfo},
peer_rpc::{PeerGroupInfo, TrustedCredentialPubkeyProof},
};
use hmac::{Hmac, KeyInit, Mac};
use hmac::{Hmac, Mac};
use sha2::Sha256;
pub use crate::config::{NetworkIdentity, NetworkSecretDigest};
@@ -28,6 +28,7 @@ use crate::{
RouteConfig, TrafficConfig,
},
events::{CoreEvent, CoreEventSink},
foundation::small_map_shard_amount,
foundation::stats::{LabelSet, LabelType, MetricName, StatsManager},
foundation::token_bucket::{ArcByteLimiter, BucketConfig, TokenBucketManager},
peers::{
@@ -71,7 +72,7 @@ pub struct PeerRuntimeSnapshotInput {
pub routes: RouteConfig,
pub network_identity: NetworkIdentity,
pub stun_info: StunInfo,
pub flags: FlagsInConfig,
pub flags: Flags,
pub secure_mode: Option<SecureModeConfig>,
pub host_routing: HostRoutingPolicy,
pub acl: Option<Acl>,
@@ -89,7 +90,7 @@ struct PeerTrafficLimits {
}
impl PeerTrafficLimits {
fn from_portable(runtime: &PeerRuntimeConfig, flags: &FlagsInConfig) -> Self {
fn from_portable(runtime: &PeerRuntimeConfig, flags: &Flags) -> Self {
let traffic = &runtime.core.traffic;
Self {
instance_recv_bps: Self::normalize(
@@ -438,7 +439,7 @@ pub(crate) struct TrustedKeyMapManager {
impl TrustedKeyMapManager {
pub fn new() -> Self {
Self {
network_trusted_keys: DashMap::new(),
network_trusted_keys: DashMap::with_shard_amount(small_map_shard_amount()),
}
}
@@ -527,7 +528,7 @@ pub(crate) struct PeerPacketPolicy {
}
impl PeerPacketPolicy {
fn from_flags(flags: &FlagsInConfig) -> Self {
fn from_flags(flags: &Flags) -> Self {
Self {
disable_relay_data: flags.disable_relay_data,
p2p_only: flags.p2p_only,
@@ -549,8 +550,8 @@ pub(crate) trait PeerContext: Send + Sync {
self.network_identity().network_name
}
fn flags(&self) -> FlagsInConfig {
FlagsInConfig::default()
fn flags(&self) -> Flags {
Flags::default()
}
fn packet_policy(&self) -> PeerPacketPolicy {
@@ -743,7 +744,7 @@ impl PeerContext for CorePeerContext {
self.snapshot().runtime.network_identity.clone()
}
fn flags(&self) -> FlagsInConfig {
fn flags(&self) -> Flags {
self.snapshot().flags.clone()
}
@@ -1082,7 +1083,7 @@ pub(crate) mod tests {
}
fn submitted_snapshot(hostname: &str, disable_relay_data: bool) -> PeerRuntimeSnapshot {
let mut flags = FlagsInConfig::default();
let mut flags = Flags::default();
flags.disable_relay_data = disable_relay_data;
PeerRuntimeSnapshot::new(
PeerRuntimeConfig {
@@ -1103,7 +1104,7 @@ pub(crate) mod tests {
)
}
fn host_snapshot_input(flags: FlagsInConfig, acl: Option<Acl>) -> PeerRuntimeSnapshotInput {
fn host_snapshot_input(flags: Flags, acl: Option<Acl>) -> PeerRuntimeSnapshotInput {
PeerRuntimeSnapshotInput {
node: NodeConfig {
peer_id: None,
@@ -1136,7 +1137,7 @@ pub(crate) mod tests {
#[test]
fn host_input_derives_peer_policy_features_and_traffic() {
let mut flags = FlagsInConfig::default();
let mut flags = Flags::default();
flags.disable_p2p = true;
flags.need_p2p = true;
flags.relay_all_peer_rpc = true;
@@ -1196,7 +1197,7 @@ pub(crate) mod tests {
}),
}),
};
let mut flags = FlagsInConfig::default();
let mut flags = Flags::default();
flags.relay_network_whitelist = "other-network".to_owned();
let snapshot = PeerRuntimeSnapshot::from_host_input(host_snapshot_input(flags, Some(acl)));
@@ -1274,7 +1275,7 @@ pub(crate) mod tests {
let mut runtime = PeerRuntimeSnapshot::default().runtime;
runtime.core.traffic.instance_recv_bps_limit = Some(0);
runtime.core.traffic.foreign_relay_bps_limit = Some(2048);
let mut flags = FlagsInConfig::default();
let mut flags = Flags::default();
flags.instance_recv_bps_limit = 1024;
flags.foreign_relay_bps_limit = 4096;
@@ -1287,7 +1288,7 @@ pub(crate) mod tests {
#[test]
fn legacy_traffic_limits_ignore_unlimited_sentinels() {
let runtime = PeerRuntimeSnapshot::default().runtime;
let mut flags = FlagsInConfig::default();
let mut flags = Flags::default();
flags.instance_recv_bps_limit = 1024;
flags.foreign_relay_bps_limit = u64::MAX;
@@ -1302,7 +1303,7 @@ pub(crate) mod tests {
let mut runtime = PeerRuntimeSnapshot::default().runtime;
runtime.core.traffic.instance_recv_bps_limit = Some(u64::MAX);
runtime.core.traffic.foreign_relay_bps_limit = Some(u64::MAX);
let mut flags = FlagsInConfig::default();
let mut flags = Flags::default();
flags.instance_recv_bps_limit = 1024;
flags.foreign_relay_bps_limit = 2048;
@@ -1315,7 +1316,7 @@ pub(crate) mod tests {
fn portable_traffic_limits_default_to_unlimited() {
let runtime = PeerRuntimeSnapshot::default().runtime;
let snapshot = PeerRuntimeSnapshot::new(runtime, FlagsInConfig::default());
let snapshot = PeerRuntimeSnapshot::new(runtime, Flags::default());
assert_eq!(snapshot.traffic_limits(), PeerTrafficLimits::default());
}
@@ -1550,7 +1551,7 @@ pub(crate) mod tests {
local_exit_node_fallback: true,
},
};
let mut flags = FlagsInConfig::default();
let mut flags = Flags::default();
flags.p2p_only = true;
let acl = Acl {
acl_v1: Some(easytier_proto::acl::AclV1 {
@@ -1626,7 +1627,7 @@ pub(crate) mod tests {
live.tcp_nat_type = 4;
let context = core_owned_context(
runtime,
FlagsInConfig::default(),
Flags::default(),
Some(Arc::new(TestStunInfoSource(live.clone()))),
);
@@ -1636,7 +1637,7 @@ pub(crate) mod tests {
fn core_owned_context(
runtime: PeerRuntimeConfig,
flags: FlagsInConfig,
flags: Flags,
stun_info_source: Option<Arc<dyn PeerStunInfoSource>>,
) -> CorePeerContext {
core_owned_context_with_acl(runtime, flags, stun_info_source, None)
@@ -1644,7 +1645,7 @@ pub(crate) mod tests {
fn core_owned_context_with_acl(
runtime: PeerRuntimeConfig,
flags: FlagsInConfig,
flags: Flags,
stun_info_source: Option<Arc<dyn PeerStunInfoSource>>,
acl: Option<&Acl>,
) -> CorePeerContext {
@@ -1679,7 +1680,7 @@ pub(crate) mod tests {
secure_mode: None,
host_routing: HostRoutingPolicy::default(),
},
FlagsInConfig::default(),
Flags::default(),
None,
)
}
+43 -37
View File
@@ -5,13 +5,14 @@ pub(crate) mod client;
use std::sync::atomic::{AtomicBool, Ordering};
use std::{
collections::HashSet,
future,
sync::{Arc, Weak},
time::SystemTime,
};
use dashmap::{DashMap, DashSet};
use easytier_proto::common::FlagsInConfig;
use dashmap::DashMap;
use easytier_proto::common::Flags;
use guarden::defer;
use tokio::sync::{
Mutex, RwLock, RwLockReadGuard,
@@ -84,7 +85,7 @@ fn build_foreign_peer_context(
network: &NetworkIdentity,
parent_context: &Arc<CorePeerContext>,
relay_data: bool,
mut flags: FlagsInConfig,
mut flags: Flags,
) -> Arc<CorePeerContext> {
let parent_context_dyn: ArcPeerContext = parent_context.clone();
let parent_flags = parent_context_dyn.flags();
@@ -358,7 +359,7 @@ struct ForeignNetworkEntry {
traffic_metrics: Arc<TrafficMetricRecorder>,
event_handler_started: AtomicBool,
indexed_peers: DashSet<PeerId>,
indexed_peers: parking_lot::Mutex<HashSet<PeerId>>,
tasks: Mutex<JoinSet<()>>,
@@ -372,7 +373,7 @@ impl ForeignNetworkEntry {
my_peer_id: PeerId,
rpc_registrar: Arc<dyn ForeignNetworkRpcRegistrar>,
parent_context: Arc<CorePeerContext>,
foreign_context_default_flags: FlagsInConfig,
foreign_context_default_flags: Flags,
relay_data: bool,
peer_session_store: Arc<PeerSessionStore>,
pm_packet_sender: PacketRecvChan,
@@ -499,7 +500,7 @@ impl ForeignNetworkEntry {
traffic_metrics,
event_handler_started: AtomicBool::new(false),
indexed_peers: DashSet::new(),
indexed_peers: parking_lot::Mutex::new(HashSet::new()),
tasks: Mutex::new(JoinSet::new()),
@@ -624,7 +625,7 @@ impl Drop for ForeignNetworkEntry {
struct ForeignNetworkManagerData {
network_peer_maps: DashMap<String, Arc<ForeignNetworkEntry>>,
peer_network_map: DashMap<PeerId, DashSet<String>>,
peer_network_map: DashMap<PeerId, HashSet<String>>,
network_peer_last_update: DashMap<String, SystemTime>,
global_peer_map: Weak<PeerMap>,
lock: std::sync::Mutex<()>,
@@ -638,19 +639,21 @@ impl ForeignNetworkManagerData {
) {
let indexed_peers = entry
.indexed_peers
.lock()
.iter()
.map(|peer_id| *peer_id)
.copied()
.collect::<Vec<_>>();
for peer_id in indexed_peers {
self.peer_network_map.remove_if(&peer_id, |_, networks| {
networks.remove(network_name);
networks.is_empty()
});
self.peer_network_map
.remove_if_mut(&peer_id, |_, networks| {
networks.remove(network_name);
networks.is_empty()
});
}
entry.indexed_peers.clear();
entry.indexed_peers.lock().clear();
}
fn get_peer_network(&self, peer_id: PeerId) -> Option<DashSet<String>> {
fn get_peer_network(&self, peer_id: PeerId) -> Option<HashSet<String>> {
self.peer_network_map.get(&peer_id).map(|v| v.clone())
}
@@ -697,11 +700,12 @@ impl ForeignNetworkManagerData {
self.remove_entry_from_peer_index_locked(network_name, &expected_entry);
self.network_peer_last_update.remove(network_name);
} else {
self.peer_network_map.remove_if(&peer_id, |_, networks| {
networks.remove(network_name);
networks.is_empty()
});
expected_entry.indexed_peers.remove(&peer_id);
self.peer_network_map
.remove_if_mut(&peer_id, |_, networks| {
networks.remove(network_name);
networks.is_empty()
});
expected_entry.indexed_peers.lock().remove(&peer_id);
}
shrink_dashmap(&self.peer_network_map, None);
shrink_dashmap(&self.network_peer_maps, None);
@@ -738,7 +742,7 @@ impl ForeignNetworkManagerData {
.entry(peer_id)
.or_default()
.insert(network_name.clone());
expected_entry.indexed_peers.insert(peer_id);
expected_entry.indexed_peers.lock().insert(peer_id);
self.network_peer_last_update
.insert(network_name.clone(), SystemTime::now());
true
@@ -803,7 +807,7 @@ impl ForeignNetworkManagerData {
.remove_if(network_name, |_, entry| {
Arc::ptr_eq(entry, &expected_entry)
&& entry.peer_map.is_empty()
&& entry.indexed_peers.is_empty()
&& entry.indexed_peers.lock().is_empty()
})
.is_some()
{
@@ -822,7 +826,7 @@ impl ForeignNetworkManagerData {
relay_data: bool,
rpc_registrar: Arc<dyn ForeignNetworkRpcRegistrar>,
parent_context: Arc<CorePeerContext>,
foreign_context_default_flags: FlagsInConfig,
foreign_context_default_flags: Flags,
peer_session_store: Arc<PeerSessionStore>,
pm_packet_sender: &PacketRecvChan,
) -> Option<(Arc<ForeignNetworkEntry>, bool)> {
@@ -874,7 +878,7 @@ enum ForeignNetworkManagerState {
pub(crate) struct ForeignNetworkManager {
rpc_registrar: Arc<dyn ForeignNetworkRpcRegistrar>,
parent_context: Arc<CorePeerContext>,
foreign_context_default_flags: FlagsInConfig,
foreign_context_default_flags: Flags,
peer_session_store: Arc<PeerSessionStore>,
packet_sender_to_mgr: PacketRecvChan,
@@ -912,7 +916,7 @@ impl ForeignNetworkManager {
pub fn new(
rpc_registrar: Arc<dyn ForeignNetworkRpcRegistrar>,
parent_context: Arc<CorePeerContext>,
foreign_context_default_flags: FlagsInConfig,
foreign_context_default_flags: Flags,
peer_session_store: Arc<PeerSessionStore>,
packet_sender_to_mgr: PacketRecvChan,
global_peer_map: Weak<PeerMap>,
@@ -1593,7 +1597,7 @@ mod tests {
};
use dashmap::DashMap;
use easytier_proto::common::{FlagsInConfig, PeerFeatureFlag};
use easytier_proto::common::{Flags, PeerFeatureFlag};
use super::{
ForeignNetworkEntry, ForeignNetworkManager, ForeignNetworkManagerData,
@@ -1638,7 +1642,7 @@ mod tests {
1,
Arc::new(()),
parent,
FlagsInConfig::default(),
Flags::default(),
true,
Arc::new(PeerSessionStore::new()),
packet_sender,
@@ -1669,10 +1673,10 @@ mod tests {
.entry(peer_id)
.or_default()
.insert(network_name.clone());
entry.indexed_peers.insert(peer_id);
entry.indexed_peers.lock().insert(peer_id);
}
data.peer_network_map
.get(&20)
.get_mut(&20)
.unwrap()
.insert(other_network.clone());
@@ -1697,11 +1701,11 @@ mod tests {
.get(&20)
.unwrap()
.iter()
.map(|network| network.clone())
.cloned()
.collect::<Vec<_>>(),
[other_network]
);
assert!(entry.indexed_peers.is_empty());
assert!(entry.indexed_peers.lock().is_empty());
assert!(!data.network_peer_last_update.contains_key(&network_name));
}
@@ -1730,7 +1734,7 @@ mod tests {
.entry(peer_id)
.or_default()
.insert(network_name.clone());
current_entry.indexed_peers.insert(peer_id);
current_entry.indexed_peers.lock().insert(peer_id);
}
assert!(
data.peer_network_map
@@ -1738,7 +1742,7 @@ mod tests {
.unwrap()
.contains(&network_name)
);
assert!(current_entry.indexed_peers.contains(&10));
assert!(current_entry.indexed_peers.lock().contains(&10));
data.remove_peer(10, &network_name, &Arc::downgrade(&old_entry));
assert!(
@@ -1759,7 +1763,7 @@ mod tests {
assert!(!data.network_peer_maps.contains_key(&network_name));
assert!(!data.peer_network_map.contains_key(&10));
assert!(!data.peer_network_map.contains_key(&11));
assert!(current_entry.indexed_peers.is_empty());
assert!(current_entry.indexed_peers.lock().is_empty());
}
impl ForeignNetworkManager {
@@ -1825,7 +1829,7 @@ mod tests {
struct FeatureContext {
avoid_relay_data: AtomicBool,
flags: FlagsInConfig,
flags: Flags,
hostname: String,
}
@@ -1833,7 +1837,7 @@ mod tests {
fn new(avoid_relay_data: bool) -> Self {
Self {
avoid_relay_data: AtomicBool::new(avoid_relay_data),
flags: FlagsInConfig::default(),
flags: Flags::default(),
hostname: String::new(),
}
}
@@ -1851,7 +1855,7 @@ mod tests {
}
}
fn flags(&self) -> FlagsInConfig {
fn flags(&self) -> Flags {
self.flags.clone()
}
@@ -1873,6 +1877,8 @@ mod tests {
assert!(check_network_in_relay_whitelist("*", "any-network").is_ok());
assert!(check_network_in_relay_whitelist("", "net1").is_err());
assert!(check_network_in_relay_whitelist("net1 net2*", "net3").is_err());
assert!(check_network_in_relay_whitelist("net1,net2*", "net1").is_ok());
assert!(check_network_in_relay_whitelist("net1, net2*", "net2-west").is_ok());
}
#[test]
@@ -1943,7 +1949,7 @@ mod tests {
network_secret: Some("secret".to_owned()),
network_secret_digest: None,
};
let mut defaults = FlagsInConfig::default();
let mut defaults = Flags::default();
defaults.mtu = 1400;
defaults.relay_network_whitelist = "baseline".to_owned();
let foreign = build_foreign_peer_context(&network, &parent, false, defaults);
+19 -2
View File
@@ -159,11 +159,28 @@ pub async fn recv_packet_from_chan(
.map(|envelope| envelope.packet)
}
/// Returned packets continue at the next peer-packet filter.
#[derive(Debug)]
pub enum PeerPacketFilterResult {
Consumed,
Pass(ZCPacket),
PassBatch(Vec<ZCPacket>),
}
impl From<Option<ZCPacket>> for PeerPacketFilterResult {
fn from(packet: Option<ZCPacket>) -> Self {
match packet {
Some(packet) => Self::Pass(packet),
None => Self::Consumed,
}
}
}
#[async_trait::async_trait]
#[auto_impl::auto_impl(Arc)]
pub trait PeerPacketFilter {
async fn try_process_packet_from_peer(&self, zc_packet: ZCPacket) -> Option<ZCPacket> {
Some(zc_packet)
async fn try_process_packet_from_peer(&self, zc_packet: ZCPacket) -> PeerPacketFilterResult {
PeerPacketFilterResult::Pass(zc_packet)
}
}
@@ -4,7 +4,7 @@ use std::{
time::{Duration, Instant},
};
use crossbeam::atomic::AtomicCell;
use crossbeam_utils::atomic::AtomicCell;
use futures::Future;
use std::sync::RwLock;
use tokio::sync::Mutex;
+114 -29
View File
@@ -37,7 +37,7 @@ use crate::{
CompressorAlgo, PacketType, ZCPacket,
compressor::{Compressor as _, DefaultCompressor},
},
proto::common::{FlagsInConfig, PeerFeatureFlag, StunInfo, Url as ProtoUrl},
proto::common::{Flags, PeerFeatureFlag, StunInfo, Url as ProtoUrl},
proto::core_peer::peer::{ListPublicIpv6InfoResponse, PeerConnInfo, Route as CoreRoute},
tunnel::{
Tunnel,
@@ -50,7 +50,7 @@ use crate::{
use super::{
BoxNicPacketFilter, BoxPeerPacketFilter, PacketRecvChanReceiver, PeerConnectionOrigin,
PeerPacketFilter, PeerPacketIngress,
PeerPacketFilter, PeerPacketFilterResult, PeerPacketIngress,
acl::AclFilter,
conn::{
peer_conn::{PeerConn, PeerConnId},
@@ -98,6 +98,7 @@ pub struct PeerSnapshot {
pub struct NodeSnapshot {
pub peer_id: PeerId,
pub ipv4_addr: Option<cidr::Ipv4Inet>,
pub ipv6_addr: Option<cidr::Ipv6Inet>,
pub proxy_networks: Vec<ProxyNetworkConfig>,
pub hostname: String,
pub stun_info: StunInfo,
@@ -213,14 +214,14 @@ pub struct PortablePeerManagerConfig {
///
/// This is explicit because those contexts participate in the same
/// handshake as the parent but do not inherit all parent policy flags.
pub foreign_context_default_flags: FlagsInConfig,
pub foreign_context_default_flags: Flags,
}
impl PortablePeerManagerConfig {
pub fn new(mut runtime: PeerRuntimeConfig) -> Self {
let policy = &runtime.core.peer_policy;
let traffic = &runtime.core.traffic;
let flags = FlagsInConfig {
let flags = Flags {
enable_encryption: policy.encryption_required,
encryption_algorithm: crate::config::EncryptionAlgorithm::default().to_string(),
disable_p2p: !policy.p2p_enabled,
@@ -283,6 +284,9 @@ fn retain_runtime_owned_peer_state(
if current.services.dhcp_ipv4 && next.services.dhcp_ipv4 {
next_peer.runtime.core.routes.ipv4 = current.peer.runtime.core.routes.ipv4.clone();
}
if current.services.dhcp_ipv6 && next.services.dhcp_ipv6 {
next_peer.runtime.core.routes.ipv6 = current.peer.runtime.core.routes.ipv6.clone();
}
}
fn validate_portable_routes(routes: &crate::config::RouteConfig) -> anyhow::Result<()> {
@@ -453,7 +457,7 @@ struct NicPacketProcessor {
#[async_trait::async_trait]
impl PeerPacketFilter for NicPacketProcessor {
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> Option<ZCPacket> {
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> PeerPacketFilterResult {
let hdr = packet.peer_manager_header().unwrap();
if hdr.packet_type == PacketType::Data as u8 && !hdr.is_not_send_to_tun() {
if hdr.is_encrypted() || hdr.is_compressed() {
@@ -464,7 +468,7 @@ impl PeerPacketFilter for NicPacketProcessor {
compressed = hdr.is_compressed(),
"dropping packet before nic because it is not fully decoded"
);
return None;
return PeerPacketFilterResult::Consumed;
}
tracing::trace!(?packet, "send packet to nic channel");
if let Err(error) = self
@@ -476,9 +480,9 @@ impl PeerPacketFilter for NicPacketProcessor {
"dropping packet because nic channel cannot accept it"
);
}
None
PeerPacketFilterResult::Consumed
} else {
Some(packet)
PeerPacketFilterResult::Pass(packet)
}
}
}
@@ -489,16 +493,16 @@ struct PeerRpcPacketProcessor {
#[async_trait::async_trait]
impl PeerPacketFilter for PeerRpcPacketProcessor {
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> Option<ZCPacket> {
async fn try_process_packet_from_peer(&self, packet: ZCPacket) -> PeerPacketFilterResult {
let hdr = packet.peer_manager_header().unwrap();
if hdr.packet_type == PacketType::TaRpc as u8
|| hdr.packet_type == PacketType::RpcReq as u8
|| hdr.packet_type == PacketType::RpcResp as u8
{
self.peer_rpc_tspt_sender.send(packet).unwrap();
None
PeerPacketFilterResult::Consumed
} else {
Some(packet)
PeerPacketFilterResult::Pass(packet)
}
}
}
@@ -956,7 +960,7 @@ impl PeerManagerCore {
is_secure_mode_enabled: bool,
data_compress_algo: CompressorAlgo,
exit_nodes: Vec<IpAddr>,
foreign_context_default_flags: FlagsInConfig,
foreign_context_default_flags: Flags,
foreign_rpc_registrar: Arc<dyn ForeignNetworkRpcRegistrar>,
) -> Self {
let stats_manager = core_context.stats_manager();
@@ -1265,6 +1269,7 @@ impl PeerManagerCore {
NodeSnapshot {
peer_id: self.my_peer_id,
ipv4_addr: self.context.ipv4(),
ipv6_addr: self.context.ipv6(),
proxy_networks: self.context.proxy_networks(),
hostname: self.context.hostname(),
stun_info: self.context.stun_info(),
@@ -1331,6 +1336,16 @@ impl PeerManagerCore {
&self,
config: CoreInstanceRuntimeConfig,
) -> anyhow::Result<Arc<CoreInstanceRuntimeConfig>> {
self.update_runtime_config_with(config, || ())
.await
.map(|(published, ())| published)
}
pub(crate) async fn update_runtime_config_with<T>(
&self,
config: CoreInstanceRuntimeConfig,
before_publish: impl FnOnce() -> T,
) -> anyhow::Result<(Arc<CoreInstanceRuntimeConfig>, T)> {
let _update = self.runtime_config_update.lock().await;
let current = self.runtime_config.snapshot();
let refresh_acl_groups = current.peer.peer_group_memberships
@@ -1340,6 +1355,9 @@ impl PeerManagerCore {
let next_acl = reload_acl.then(|| config.services.acl.clone());
let next_built_acl = next_acl.as_ref().map(AclRuleConfig::build).transpose()?;
// Commit prepared host resources only after all fallible validation,
// with no cancellation point before publishing their configuration.
let committed = before_publish();
self.set_avoid_relay_data_preference(config.peer.avoid_relay_data_preference);
let published = self
.runtime_config
@@ -1352,7 +1370,7 @@ impl PeerManagerCore {
if refresh_acl_groups {
self.route.refresh_acl_groups().await;
}
Ok(published)
Ok((published, committed))
}
/// Keeps this manager's ACL rules and group assignments aligned with a
@@ -1751,6 +1769,13 @@ impl PeerManagerCore {
*self.exit_nodes.write().await = exit_nodes;
}
/// Shares the network's current exit nodes, including later updates, while
/// keeping this peer's route lookup and packet identity independent.
pub(super) fn inherit_exit_nodes(&mut self, network: &Self) {
self.exit_nodes = network.exit_nodes.clone();
self.outbound_packet_router.exit_nodes = self.exit_nodes.clone();
}
fn reload_acl(&self, acl: Option<&crate::proto::acl::Acl>) {
self.acl_filter.reload_rules(acl);
}
@@ -3175,25 +3200,33 @@ impl PeerPacketRouter {
return;
}
let mut processed = false;
let mut zc_packet = Some(ret);
tracing::trace!(?zc_packet, "try_process_packet_from_peer");
tracing::trace!(?ret, "try_process_packet_from_peer");
let pipelines = self.peer_packet_process_pipeline.load_full();
for pipeline in pipelines.iter().rev() {
if let Some(filter) = pipeline.filter_if_active() {
zc_packet = filter
.try_process_packet_from_peer(zc_packet.unwrap())
.await;
process_peer_packet(&pipelines, ret).await;
}
}
}
async fn process_peer_packet(pipelines: &[PeerPipelineEntry], packet: ZCPacket) {
let mut first = Some((packet, pipelines.len()));
// The ordinary single-packet path does not allocate this queue.
let mut pending = Vec::new();
'packets: while let Some((mut packet, next)) = first.take().or_else(|| pending.pop()) {
for (index, pipeline) in pipelines[..next].iter().enumerate().rev() {
let Some(filter) = pipeline.filter_if_active() else {
continue;
};
match filter.try_process_packet_from_peer(packet).await {
PeerPacketFilterResult::Consumed => continue 'packets,
PeerPacketFilterResult::Pass(next) => packet = next,
PeerPacketFilterResult::PassBatch(packets) => {
// Resume each packet after the filter that produced it.
pending.extend(packets.into_iter().rev().map(|packet| (packet, index)));
continue 'packets;
}
if zc_packet.is_none() {
processed = true;
break;
}
}
if !processed {
tracing::error!(?zc_packet, "unhandled packet");
}
}
tracing::error!(?packet, "unhandled packet");
}
}
@@ -3538,6 +3571,58 @@ mod tests {
}
}
#[tokio::test]
async fn packet_batches_resume_at_next_filter_in_order() {
struct Filter {
stage: u8,
seen: Arc<std::sync::Mutex<Vec<(u8, u8)>>>,
}
#[async_trait::async_trait]
impl PeerPacketFilter for Filter {
async fn try_process_packet_from_peer(
&self,
packet: ZCPacket,
) -> PeerPacketFilterResult {
self.seen
.lock()
.unwrap()
.push((self.stage, packet.payload()[0]));
match self.stage {
0 => PeerPacketFilterResult::Consumed,
1 | 2 => {
let mut extra = packet.clone();
extra.mut_payload()[0] += if self.stage == 1 { 10 } else { 1 };
PeerPacketFilterResult::PassBatch(vec![packet, extra])
}
_ => PeerPacketFilterResult::Pass(packet),
}
}
}
let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
let pipelines = (0..4)
.map(|stage| {
permanent_peer_pipeline_entry(Box::new(Filter {
stage,
seen: seen.clone(),
}))
})
.collect::<Vec<_>>();
process_peer_packet(&pipelines, ZCPacket::new_with_payload(&[0])).await;
assert_eq!(
*seen.lock().unwrap(),
vec![
(3, 0),
(2, 0),
(1, 0),
(0, 0),
(0, 10),
(1, 1),
(0, 1),
(0, 11),
]
);
}
struct RuntimeConfigStunInfoSource(CoreRuntimeConfigStore);
impl PeerStunInfoSource for RuntimeConfigStunInfoSource {
@@ -3607,7 +3692,7 @@ mod tests {
.await;
assert!(result.is_ok(), "a full host queue blocked the peer router");
assert!(result.unwrap().is_none());
assert!(matches!(result.unwrap(), PeerPacketFilterResult::Consumed));
assert_eq!(nic_receiver.try_recv().unwrap().payload(), b"queued");
}
+9 -8
View File
@@ -8,6 +8,7 @@ use tokio::sync::{Mutex, OwnedMutexGuard, oneshot};
use crate::{
config::PeerId,
foundation::small_map_shard_amount,
foundation::time::{Duration, timeout},
packet::{PacketType, ZCPacket},
peers::{
@@ -146,10 +147,10 @@ impl RelayPeerMap {
metric_network_name,
my_peer_id,
peer_session_store,
states: DashMap::new(),
pending_handshakes: DashMap::new(),
handshake_locks: DashMap::new(),
pending_packets: DashMap::new(),
states: DashMap::with_shard_amount(small_map_shard_amount()),
pending_handshakes: DashMap::with_shard_amount(small_map_shard_amount()),
handshake_locks: DashMap::with_shard_amount(small_map_shard_amount()),
pending_packets: DashMap::with_shard_amount(small_map_shard_amount()),
is_secure_mode_enabled,
})
}
@@ -774,13 +775,13 @@ mod tests {
use super::*;
use crate::{
peers::context::{ArcPeerContext, NetworkIdentity, PeerContext},
proto::common::{FlagsInConfig, SecureModeConfig},
proto::common::{Flags, SecureModeConfig},
};
struct RelayTestContext {
network_identity: NetworkIdentity,
secure_mode: SecureModeConfig,
flags: FlagsInConfig,
flags: Flags,
}
impl PeerContext for RelayTestContext {
@@ -792,7 +793,7 @@ mod tests {
Some(self.secure_mode.clone())
}
fn flags(&self) -> FlagsInConfig {
fn flags(&self) -> Flags {
self.flags.clone()
}
}
@@ -866,7 +867,7 @@ mod tests {
local_private_key: Some(BASE64_STANDARD.encode(private.as_bytes())),
local_public_key: Some(BASE64_STANDARD.encode(public.as_bytes())),
},
flags: FlagsInConfig {
flags: Flags {
encryption_algorithm: "aes-gcm".to_owned(),
..Default::default()
},
@@ -12,7 +12,7 @@ use std::{
use arc_swap::ArcSwap;
use atomic_shim::AtomicU64;
use cidr::{IpCidr, Ipv4Cidr, Ipv6Cidr, Ipv6Inet};
use crossbeam::atomic::AtomicCell;
use crossbeam_utils::atomic::AtomicCell;
use dashmap::DashMap;
use ordered_hash_map::OrderedHashMap;
use parking_lot::{RwLock, lock_api::RwLockUpgradableReadGuard};
@@ -34,6 +34,7 @@ use tokio::{
use crate::{
config::PeerId,
config::peers::PeerGroupIdentity,
foundation::small_map_shard_amount,
peers::{
PeerPacketFilter,
context::{
@@ -200,7 +201,7 @@ impl OspfRouteSnapshotLookup<'_> {
}
type PeerGraph = Graph<PeerId, usize, Directed>;
type PeerIdToNodeIdxMap = DashMap<PeerId, NodeIndex>;
type PeerIdToNodeIdxMap = HashMap<PeerId, NodeIndex>;
#[derive(Debug, Clone, Copy)]
pub struct OspfNextHopInfo {
@@ -228,11 +229,11 @@ pub struct OspfRouteTable {
impl OspfRouteTable {
pub fn new() -> Self {
OspfRouteTable {
peer_infos: DashMap::new(),
next_hop_map: DashMap::new(),
suppressed_peer_ids: DashMap::new(),
ipv4_peer_id_map: DashMap::new(),
ipv6_peer_id_map: DashMap::new(),
peer_infos: DashMap::with_shard_amount(small_map_shard_amount()),
next_hop_map: DashMap::with_shard_amount(small_map_shard_amount()),
suppressed_peer_ids: DashMap::with_shard_amount(small_map_shard_amount()),
ipv4_peer_id_map: DashMap::with_shard_amount(small_map_shard_amount()),
ipv6_peer_id_map: DashMap::with_shard_amount(small_map_shard_amount()),
cidr_peer_id_map: ArcSwap::new(Arc::new(PrefixMap::new())),
cidr_v6_peer_id_map: ArcSwap::new(Arc::new(PrefixMap::new())),
next_hop_map_version: AtomicVersion::new(),
@@ -301,7 +302,7 @@ impl OspfRouteTable {
let mut graph: PeerGraph = PeerGraph::new();
let mut start_node_idx = None;
let peer_id_to_node_index: PeerIdToNodeIdxMap = DashMap::new();
let mut peer_id_to_node_index = PeerIdToNodeIdxMap::new();
for entry in &snapshot.peer_infos {
let peer_id = entry.peer_id;
@@ -321,12 +322,10 @@ impl OspfRouteTable {
return (graph, NodeIndex::end());
}
for item in peer_id_to_node_index.iter() {
let src_peer_id = *item.key();
for (&src_peer_id, src_node_idx) in &peer_id_to_node_index {
if src_peer_id != my_peer_id && snapshot.suppressed_peer_ids.contains(&src_peer_id) {
continue;
}
let src_node_idx = item.value();
let connected_peers: BTreeSet<_> =
lookup.connected_peers(src_peer_id).unwrap_or_default();
@@ -2035,9 +2034,9 @@ impl SyncRouteSession {
SyncRouteSession {
my_peer_id,
dst_peer_id,
dst_saved_peer_info_versions: DashMap::new(),
dst_saved_conn_info_version: DashMap::new(),
dst_saved_foreign_network_versions: DashMap::new(),
dst_saved_peer_info_versions: DashMap::with_shard_amount(small_map_shard_amount()),
dst_saved_conn_info_version: DashMap::with_shard_amount(small_map_shard_amount()),
dst_saved_foreign_network_versions: DashMap::with_shard_amount(small_map_shard_amount()),
unreachable_peers_for_peer_info: parking_lot::Mutex::new(BTreeMap::new()),
unreachable_peers_for_conn_info: parking_lot::Mutex::new(BTreeMap::new()),
@@ -2412,7 +2411,7 @@ impl PeerRouteServiceImpl {
my_peer_id,
my_peer_route_id: rand::random(),
context: context.clone(),
sessions: DashMap::new(),
sessions: DashMap::with_shard_amount(small_map_shard_amount()),
stopped: AtomicBool::new(false),
session_operation: AsyncRwLock::new(()),
@@ -2422,21 +2421,23 @@ impl PeerRouteServiceImpl {
route_table: OspfRouteTable::new(),
route_table_with_cost: OspfRouteTable::new(),
foreign_network_owner_map: DashMap::new(),
foreign_network_my_peer_id_map: DashMap::new(),
foreign_network_owner_map: DashMap::with_shard_amount(small_map_shard_amount()),
foreign_network_my_peer_id_map: DashMap::with_shard_amount(small_map_shard_amount()),
synced_route_info: SyncedRouteInfo {
default_easytier_version: context.easytier_version(),
peer_infos: RwLock::new(OrderedHashMap::new()),
raw_peer_infos: DashMap::new(),
raw_peer_infos: DashMap::with_shard_amount(small_map_shard_amount()),
conn_map: RwLock::new(OrderedHashMap::new()),
foreign_network: DashMap::new(),
group_trust_map: DashMap::new(),
group_trust_map_cache: DashMap::new(),
foreign_network: DashMap::with_shard_amount(small_map_shard_amount()),
group_trust_map: DashMap::with_shard_amount(small_map_shard_amount()),
group_trust_map_cache: DashMap::with_shard_amount(small_map_shard_amount()),
group_trust_update_lock: parking_lot::Mutex::new(()),
trusted_credential_pubkeys: RwLock::new(HashMap::new()),
non_reusable_credential_owners: DashMap::new(),
suppressed_non_reusable_credential_peers: DashMap::new(),
non_reusable_credential_owners: DashMap::with_shard_amount(small_map_shard_amount()),
suppressed_non_reusable_credential_peers: DashMap::with_shard_amount(
small_map_shard_amount(),
),
version: AtomicVersion::new(),
},
public_ipv6_service: std::sync::Mutex::new(Weak::new()),
@@ -4886,8 +4887,8 @@ mod tests {
CoreNetworkIdentity::default()
}
fn flags(&self) -> crate::proto::common::FlagsInConfig {
crate::proto::common::FlagsInConfig {
fn flags(&self) -> crate::proto::common::Flags {
crate::proto::common::Flags {
prefer_peer_relay: self.enabled.load(Ordering::Relaxed),
..Default::default()
}
@@ -5012,7 +5013,7 @@ mod tests {
}
fn test_peer_relay_service_impl(my_peer_id: PeerId) -> PeerRouteServiceImpl {
let flags = crate::proto::common::FlagsInConfig {
let flags = crate::proto::common::Flags {
prefer_peer_relay: true,
..Default::default()
};
+5 -5
View File
@@ -6,7 +6,7 @@
use std::net::IpAddr;
use cidr::{Ipv4Inet, Ipv6Inet};
use easytier_proto::common::{FlagsInConfig, SecureModeConfig};
use easytier_proto::common::{Flags, SecureModeConfig};
use hmac::Hmac;
use sha2::Sha256;
@@ -58,7 +58,7 @@ fn ipv6_inet_to_config(value: Ipv6Inet) -> IpPrefix {
#[derive(Debug, Clone)]
pub(crate) struct NoopPeerContext {
network_identity: NetworkIdentity,
flags: FlagsInConfig,
flags: Flags,
secure_mode: Option<SecureModeConfig>,
}
@@ -66,7 +66,7 @@ impl NoopPeerContext {
pub(crate) fn new(network_identity: NetworkIdentity) -> Self {
Self {
network_identity,
flags: FlagsInConfig::default(),
flags: Flags::default(),
secure_mode: None,
}
}
@@ -77,7 +77,7 @@ impl NoopPeerContext {
self
}
pub(crate) fn with_flags(mut self, flags: FlagsInConfig) -> Self {
pub(crate) fn with_flags(mut self, flags: Flags) -> Self {
self.flags = flags;
self
}
@@ -94,7 +94,7 @@ impl PeerContext for NoopPeerContext {
self.network_identity.clone()
}
fn flags(&self) -> FlagsInConfig {
fn flags(&self) -> Flags {
self.flags.clone()
}
+2 -1
View File
@@ -4,6 +4,7 @@ use dashmap::DashMap;
use futures::future::BoxFuture;
use crate::config::PeerId;
use crate::foundation::small_map_shard_amount;
use crate::foundation::stats::{CounterHandle, LabelSet, LabelType, MetricName, StatsManager};
use crate::packet::{PacketType, ZCPacket};
use crate::peers::util::shrink_dashmap;
@@ -88,7 +89,7 @@ impl LogicalTrafficMetrics {
instance_bytes_metric,
instance_packets_metric,
label_kind,
per_peer: DashMap::new(),
per_peer: DashMap::with_shard_amount(small_map_shard_amount()),
}
}
+2 -1
View File
@@ -7,7 +7,8 @@ pub(crate) fn check_network_in_relay_whitelist(
network_name: &str,
) -> Result<(), anyhow::Error> {
if relay_network_whitelist
.split(' ')
.split(|c: char| c.is_whitespace() || c == ',')
.filter(|s| !s.is_empty())
.map(wildmatch::WildMatch::new)
.any(|whitelist| whitelist.matches(network_name))
{
+3 -2
View File
@@ -14,6 +14,7 @@ use tokio::{sync::mpsc, task::JoinSet};
use crate::{
config::PeerId,
foundation::{
small_map_shard_amount,
stats::{ArcRpcMetrics, RpcMetricLabels, RpcMetricsProvider},
time::timeout,
},
@@ -109,8 +110,8 @@ impl Client {
Self {
mpsc: Mutex::new(MpscTunnel::new(ring_a, None)),
transport: Mutex::new(MpscTunnel::new(ring_b, None)),
inflight_requests: Arc::new(DashMap::new()),
peer_info: Arc::new(DashMap::new()),
inflight_requests: Arc::new(DashMap::with_shard_amount(small_map_shard_amount())),
peer_info: Arc::new(DashMap::with_shard_amount(small_map_shard_amount())),
tasks: Mutex::new(JoinSet::new()),
metrics: None,
}
+2 -1
View File
@@ -14,6 +14,7 @@ use tokio::task::JoinSet;
use crate::{
foundation::{
small_map_shard_amount,
stats::{ArcRpcMetrics, RpcMetricLabels, RpcMetricsProvider},
task::reap_joinset_background,
},
@@ -77,7 +78,7 @@ impl Server {
tasks: Arc::new(Mutex::new(JoinSet::new())),
handler_tasks: Arc::new(Mutex::new(JoinSet::new())),
stopped: Arc::new(AtomicBool::new(false)),
packet_mergers: Arc::new(DashMap::new()),
packet_mergers: Arc::new(DashMap::with_shard_amount(small_map_shard_amount())),
metrics: None,
}
}
+2 -1
View File
@@ -2,6 +2,7 @@ use std::sync::Arc;
use dashmap::DashMap;
use crate::foundation::small_map_shard_amount;
use crate::proto::common::RpcDescriptor;
use crate::proto::rpc_types;
#[cfg(all(feature = "management-rpc", any(test, target_os = "wasi")))]
@@ -86,7 +87,7 @@ impl Default for ServiceRegistry {
impl ServiceRegistry {
pub fn new() -> Self {
Self {
table: DashMap::new(),
table: DashMap::with_shard_amount(small_map_shard_amount()),
}
}
+38 -8
View File
@@ -25,12 +25,13 @@ use super::{
session::{
ClassifiedUdpSessionAccept, ClassifiedUdpSessionAccepts, ClassifiedUdpSessionKey,
ClassifiedUdpSessionRegistry, PendingUdpSessionConnect, PendingUdpSessionConnects,
UdpConnectControl, UdpSession, UdpSessionClose, UdpSessionCodec, UdpSessionConnectError,
UdpSessionConnectRequest, UdpSessionConnector, UdpSessionDatagram, UdpSessionEnqueuePolicy,
UdpSessionKey, UdpSessionKind, UdpSessionLayerControl, UdpSessionProtocol,
UdpSessionRegistry, close_all_classified_udp_sessions, close_all_udp_sessions,
close_classified_udp_session, close_udp_session, create_udp_session_rings,
dispatch_payload_to_session, udp_session_registry_entry,
UdpConnectControl, UdpHolePunchObserverMap, UdpHolePunchObserverRegistry, UdpSession,
UdpSessionClose, UdpSessionCodec, UdpSessionConnectError, UdpSessionConnectRequest,
UdpSessionConnector, UdpSessionDatagram, UdpSessionEnqueuePolicy, UdpSessionKey,
UdpSessionKind, UdpSessionLayerControl, UdpSessionProtocol, UdpSessionRegistry,
close_all_classified_udp_sessions, close_all_udp_sessions, close_classified_udp_session,
close_udp_session, create_udp_session_rings, dispatch_payload_to_session,
udp_session_registry_entry,
},
virtual_socket::{
MAX_UDP_SESSION_DATAGRAM_SIZE, NoopUdpSessionStunResponder, PreferredIpv6Source,
@@ -49,6 +50,7 @@ pub struct UdpSessionLayer<S, R = NoopUdpSessionStunResponder> {
classified_sessions: Arc<ClassifiedUdpSessionRegistry>,
pub(super) classified_accepts: Arc<ClassifiedUdpSessionAccepts>,
pub(super) pending_connects: Arc<PendingUdpSessionConnects>,
hole_punch_observers: UdpHolePunchObserverRegistry,
mux_accepted_rx: TokioMutex<mpsc::Receiver<UdpSession>>,
_control_rx: TokioMutex<mpsc::Receiver<UdpSessionLayerControl>>,
session_shutdown_tx: watch::Sender<bool>,
@@ -90,15 +92,17 @@ where
let classified_sessions = Arc::new(DashMap::new());
let classified_accepts = create_classified_udp_session_accepts();
let pending_connects = Arc::new(DashMap::new());
let hole_punch_observers = Arc::new(DashMap::new());
let (mux_accepted_tx, mux_accepted_rx) = mpsc::channel(UDP_SESSION_QUEUE_CAPACITY);
let (control_tx, control_rx) = mpsc::channel(UDP_SESSION_QUEUE_CAPACITY);
let (session_shutdown_tx, _) = watch::channel(false);
let (session_shutdown_tx, session_shutdown_rx) = watch::channel(false);
let recv_task = tokio::spawn(udp_session_layer_recv_task(
socket.clone(),
sessions.clone(),
classified_sessions.clone(),
classified_accepts.clone(),
pending_connects.clone(),
hole_punch_observers.clone(),
mux_accepted_tx,
control_tx,
stun_responder.clone(),
@@ -112,6 +116,10 @@ where
classified_sessions,
classified_accepts,
pending_connects,
hole_punch_observers: UdpHolePunchObserverRegistry {
observers: hole_punch_observers,
shutdown: session_shutdown_rx,
},
mux_accepted_rx: TokioMutex::new(mux_accepted_rx),
_control_rx: TokioMutex::new(control_rx),
session_shutdown_tx,
@@ -123,6 +131,10 @@ where
self.socket.local_addr()
}
pub fn hole_punch_observers(&self) -> UdpHolePunchObserverRegistry {
self.hole_punch_observers.clone()
}
pub fn active_session_count(&self) -> usize {
self.sessions.len()
}
@@ -355,6 +367,7 @@ impl<S, R> Drop for UdpSessionLayer<S, R> {
fn drop(&mut self) {
let _ = self.session_shutdown_tx.send(true);
self.pending_connects.clear();
self.hole_punch_observers.observers.clear();
close_all_udp_sessions(&self.sessions);
close_all_classified_udp_sessions(&self.classified_sessions);
self.recv_task.abort();
@@ -437,6 +450,7 @@ pub(super) async fn udp_session_layer_recv_task<S, R>(
classified_sessions: Arc<ClassifiedUdpSessionRegistry>,
classified_accepts: Arc<ClassifiedUdpSessionAccepts>,
pending_connects: Arc<PendingUdpSessionConnects>,
hole_punch_observers: Arc<UdpHolePunchObserverMap>,
mux_accepted: mpsc::Sender<UdpSession>,
control: mpsc::Sender<UdpSessionLayerControl>,
stun_responder: Arc<R>,
@@ -453,6 +467,7 @@ pub(super) async fn udp_session_layer_recv_task<S, R>(
tracing::debug!(?err, "udp session recv loop stopped");
let _ = session_shutdown_tx.send(true);
pending_connects.clear();
hole_punch_observers.clear();
close_all_udp_sessions(&sessions);
close_all_classified_udp_sessions(&classified_sessions);
break;
@@ -519,6 +534,7 @@ pub(super) async fn udp_session_layer_recv_task<S, R>(
&socket,
&sessions,
&pending_connects,
&hole_punch_observers,
&mux_accepted,
&control,
&control_permits,
@@ -570,6 +586,7 @@ fn dispatch_easy_tier_udp_datagram<S>(
socket: &Arc<S>,
sessions: &Arc<UdpSessionRegistry>,
pending_connects: &Arc<PendingUdpSessionConnects>,
hole_punch_observers: &Arc<UdpHolePunchObserverMap>,
mux_accepted: &mpsc::Sender<UdpSession>,
control: &mpsc::Sender<UdpSessionLayerControl>,
control_permits: &Arc<Semaphore>,
@@ -600,7 +617,20 @@ where
dispatch_sack_packet(sessions, pending_connects, remote_addr, conn_id, &packet)
}
EasyTierUdpPacketKind::HolePunch => {
dispatch_hole_punch_packet(pending_connects, remote_addr)
let mut observed = false;
if packet.udp_payload().len() == crate::packet::HOLE_PUNCH_PACKET_BODY_LEN as usize
&& let Some(observer) = hole_punch_observers.get(&conn_id)
{
observer.source.send_if_modified(|source| {
if source.is_some() {
return false;
}
*source = Some(remote_addr);
true
});
observed = true;
}
dispatch_hole_punch_packet(pending_connects, remote_addr) || observed
}
EasyTierUdpPacketKind::V4HolePunch => dispatch_v4_hole_punch_control(
socket.clone(),
+4 -3
View File
@@ -20,9 +20,10 @@ pub use packet::{
parse_udp_session_datagram,
};
pub use session::{
UdpSession, UdpSessionConnectError, UdpSessionConnectRequest, UdpSessionConnector,
UdpSessionKind, UdpSessionLayerControl, UdpSessionListenRequest, UdpSessionListener,
UdpSessionProtocol, UdpSessionRecvMeta, UdpSessionSocket,
UdpHolePunchObserver, UdpHolePunchObserverRegistry, UdpSession, UdpSessionConnectError,
UdpSessionConnectRequest, UdpSessionConnector, UdpSessionKind, UdpSessionLayerControl,
UdpSessionListenRequest, UdpSessionListener, UdpSessionProtocol, UdpSessionRecvMeta,
UdpSessionSocket,
};
pub(crate) use session::{
UdpSessionCleanup, UdpSessionCodec, UdpSessionDatagram, UdpSessionOutbound,
+131 -1
View File
@@ -1,7 +1,7 @@
use std::{
io,
net::{IpAddr, SocketAddr, SocketAddrV4, SocketAddrV6},
sync::{Arc, Mutex as StdMutex},
sync::{Arc, Mutex as StdMutex, Weak},
};
use async_trait::async_trait;
@@ -15,6 +15,7 @@ use tokio::{
};
use crate::{
foundation::time::{Duration, Instant},
packet::{UdpPacketType, ZCPacket, ZCPacketType},
socket::ring::{RingSocket, RingSocketReceiver, RingSocketSendError, RingSocketSender},
};
@@ -201,6 +202,135 @@ pub(super) type ClassifiedUdpSessionRegistry =
pub(super) type ClassifiedUdpSessionAccepts =
DashMap<UdpSessionProtocol, Arc<ClassifiedUdpSessionAccept>>;
pub(super) type PendingUdpSessionConnects = DashMap<u32, PendingUdpSessionConnect>;
pub(super) type UdpHolePunchObserverMap = DashMap<u32, UdpHolePunchObserverEntry>;
#[derive(Debug)]
pub(super) struct UdpHolePunchObserverEntry {
pub(super) source: Arc<watch::Sender<Option<SocketAddr>>>,
// Only unclaimed listener-selection registrations expire here. Once an RPC
// takes the observer, its guard owns cleanup through completion/cancellation.
expires_at: Option<Instant>,
}
/// Registers observers on an existing UDP session layer without another receive task.
#[derive(Debug, Clone)]
pub struct UdpHolePunchObserverRegistry {
pub(super) observers: Arc<UdpHolePunchObserverMap>,
pub(super) shutdown: watch::Receiver<bool>,
}
impl UdpHolePunchObserverRegistry {
fn ensure_running(&self) -> io::Result<()> {
if *self.shutdown.borrow() {
return Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"UDP listener closed",
));
}
Ok(())
}
/// Keep the first source even if the UDP packet arrives before the sending RPC.
pub fn prepare(&self, transaction_id: u32, timeout: Duration) -> io::Result<()> {
self.ensure_running()?;
match self.observers.entry(transaction_id) {
dashmap::mapref::entry::Entry::Vacant(entry) => {
let (sender, _) = watch::channel(None);
entry.insert(UdpHolePunchObserverEntry {
source: Arc::new(sender),
expires_at: Some(Instant::now() + timeout),
});
Ok(())
}
dashmap::mapref::entry::Entry::Occupied(_) => Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"UDP hole-punch transaction already observed",
)),
}
}
pub fn expire_prepared(&self) {
let now = Instant::now();
self.observers
.retain(|_, entry| entry.expires_at.is_none_or(|deadline| deadline > now));
}
pub fn observe(&self, transaction_id: u32) -> io::Result<UdpHolePunchObserver> {
self.ensure_running()?;
let sender = match self.observers.entry(transaction_id) {
dashmap::mapref::entry::Entry::Occupied(mut entry) => {
let Some(deadline) = entry.get_mut().expires_at.take() else {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"UDP hole-punch transaction already observed",
));
};
if deadline <= Instant::now() {
entry.remove();
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"UDP hole-punch registration expired",
));
}
entry.get().source.clone()
}
dashmap::mapref::entry::Entry::Vacant(entry) => {
// Older clients select a listener without preparing a transaction.
let (sender, _) = watch::channel(None);
let sender = Arc::new(sender);
entry.insert(UdpHolePunchObserverEntry {
source: sender.clone(),
expires_at: None,
});
sender
}
};
Ok(UdpHolePunchObserver {
transaction_id,
observers: self.observers.clone(),
sender: Arc::downgrade(&sender),
source: sender.subscribe(),
})
}
}
/// Observes the first valid source for one active hole-punch transaction.
/// Dropping the observer unregisters it from the socket's receive loop.
#[derive(Debug)]
pub struct UdpHolePunchObserver {
pub(super) transaction_id: u32,
pub(super) observers: Arc<UdpHolePunchObserverMap>,
pub(super) sender: Weak<watch::Sender<Option<SocketAddr>>>,
pub(super) source: watch::Receiver<Option<SocketAddr>>,
}
impl UdpHolePunchObserver {
pub fn source_addr(&self) -> Option<SocketAddr> {
*self.source.borrow()
}
pub async fn wait_for_source_addr(&mut self) -> io::Result<SocketAddr> {
loop {
if let Some(addr) = self.source_addr() {
return Ok(addr);
}
self.source.changed().await.map_err(|_| {
io::Error::new(
io::ErrorKind::UnexpectedEof,
"UDP hole-punch observer closed",
)
})?;
}
}
}
impl Drop for UdpHolePunchObserver {
fn drop(&mut self) {
self.observers.remove_if(&self.transaction_id, |_, entry| {
self.sender.ptr_eq(&Arc::downgrade(&entry.source))
});
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(super) struct ClassifiedUdpSessionKey {
+178
View File
@@ -477,6 +477,182 @@ impl AutoSackVirtualUdpSocket {
}
}
#[tokio::test]
async fn hole_punch_observer_matches_socket_transaction_and_packet_shape() {
let local_addr = SocketAddr::from(([127, 0, 0, 1], 12000));
let peer_addr = SocketAddr::from(([198, 51, 100, 1], 33140));
let other_addr = SocketAddr::from(([198, 51, 100, 1], 33105));
let socket = Arc::new(AutoSackVirtualUdpSocket::new(local_addr));
let other_socket = Arc::new(AutoSackVirtualUdpSocket::new(local_addr));
let layer = UdpSessionLayer::new(socket.clone());
let other_layer = UdpSessionLayer::new(other_socket.clone());
let mut observer = layer.hole_punch_observers().observe(7).unwrap();
let other_observer = other_layer.hole_punch_observers().observe(7).unwrap();
let mut concurrent_observer = layer.hole_punch_observers().observe(8).unwrap();
for (tid, body_len, source) in [
(9, crate::packet::HOLE_PUNCH_PACKET_BODY_LEN, other_addr),
(7, 32, other_addr),
(8, crate::packet::HOLE_PUNCH_PACKET_BODY_LEN, other_addr),
(7, crate::packet::HOLE_PUNCH_PACKET_BODY_LEN, peer_addr),
(7, crate::packet::HOLE_PUNCH_PACKET_BODY_LEN, other_addr),
] {
socket.incoming.lock().unwrap().push_back((
crate::packet::new_hole_punch_packet(tid, body_len)
.into_bytes()
.to_vec(),
source,
));
}
socket.incoming_notify.notify_one();
assert_eq!(
tokio::time::timeout(Duration::from_secs(1), observer.wait_for_source_addr())
.await
.unwrap()
.unwrap(),
peer_addr,
);
assert_eq!(
concurrent_observer.wait_for_source_addr().await.unwrap(),
other_addr
);
assert_eq!(observer.source_addr(), Some(peer_addr));
assert_eq!(other_observer.source_addr(), None);
assert!(socket.sent().is_empty());
assert_eq!(
layer.hole_punch_observers().observe(7).unwrap_err().kind(),
io::ErrorKind::AlreadyExists
);
drop(observer);
assert_eq!(
layer
.hole_punch_observers()
.observe(7)
.unwrap()
.source_addr(),
None
);
}
#[tokio::test]
async fn prepared_hole_punch_keeps_source_until_rpc_claims_it() {
let socket = Arc::new(AutoSackVirtualUdpSocket::new(SocketAddr::from((
[127, 0, 0, 1],
12000,
))));
let layer = UdpSessionLayer::new(socket.clone());
let registry = layer.hole_punch_observers();
registry.prepare(0, Duration::from_secs(30)).unwrap();
let peer_addr = SocketAddr::from(([198, 51, 100, 1], 33140));
socket.incoming.lock().unwrap().push_back((
crate::packet::new_hole_punch_packet(0, crate::packet::HOLE_PUNCH_PACKET_BODY_LEN)
.into_bytes()
.to_vec(),
peer_addr,
));
socket.incoming_notify.notify_one();
tokio::time::timeout(Duration::from_secs(1), async {
loop {
if *registry.observers.get(&0).unwrap().source.borrow() == Some(peer_addr) {
break;
}
tokio::task::yield_now().await;
}
})
.await
.unwrap();
let observer = registry.observe(0).unwrap();
assert_eq!(observer.source_addr(), Some(peer_addr));
assert_eq!(
registry.observe(0).unwrap_err().kind(),
io::ErrorKind::AlreadyExists
);
drop(observer);
assert!(registry.observers.is_empty());
}
#[tokio::test(start_paused = true)]
async fn prepared_hole_punch_expires_without_removing_active_rpc() {
let socket = Arc::new(AutoSackVirtualUdpSocket::new(SocketAddr::from((
[127, 0, 0, 1],
12000,
))));
let layer = UdpSessionLayer::new(socket);
let registry = layer.hole_punch_observers();
registry.prepare(7, Duration::from_secs(1)).unwrap();
registry.prepare(8, Duration::from_secs(1)).unwrap();
registry.prepare(9, Duration::from_secs(1)).unwrap();
let observer = registry.observe(8).unwrap();
tokio::time::advance(Duration::from_secs(1)).await;
assert_eq!(
registry.observe(7).unwrap_err().kind(),
io::ErrorKind::TimedOut
);
registry.expire_prepared();
assert!(!registry.observers.contains_key(&9));
assert_eq!(
registry.observe(8).unwrap_err().kind(),
io::ErrorKind::AlreadyExists
);
registry.prepare(9, Duration::from_secs(1)).unwrap();
drop(observer);
assert!(!registry.observers.contains_key(&8));
}
#[tokio::test]
async fn dropping_hole_punch_observer_preserves_udp_handshake_receive() {
let socket = Arc::new(AutoSackVirtualUdpSocket::new(SocketAddr::from((
[127, 0, 0, 1],
12000,
))));
let layer = UdpSessionLayer::new(socket);
let observer = layer.hole_punch_observers().observe(7).unwrap();
drop(observer);
let peer_addr = SocketAddr::from(([198, 51, 100, 1], 33140));
let session = layer.connect(peer_addr).await.unwrap();
assert_eq!(session.peer_addr().unwrap(), peer_addr);
}
#[tokio::test]
async fn hole_punch_observer_registry_does_not_keep_listener_alive() {
let socket = Arc::new(AutoSackVirtualUdpSocket::new(SocketAddr::from((
[127, 0, 0, 1],
12000,
))));
let layer = UdpSessionLayer::new(socket.clone());
let registry = layer.hole_punch_observers();
let mut observer = registry.observe(7).unwrap();
registry.prepare(8, Duration::from_secs(30)).unwrap();
drop(layer);
assert_eq!(
registry.observe(8).unwrap_err().kind(),
io::ErrorKind::BrokenPipe
);
assert_eq!(
registry
.prepare(9, Duration::from_secs(30))
.unwrap_err()
.kind(),
io::ErrorKind::BrokenPipe
);
assert_eq!(
observer.wait_for_source_addr().await.unwrap_err().kind(),
io::ErrorKind::UnexpectedEof
);
drop(observer);
for _ in 0..10 {
if Arc::strong_count(&socket) == 1 {
return;
}
tokio::task::yield_now().await;
}
assert_eq!(Arc::strong_count(&socket), 1);
}
#[async_trait]
impl VirtualUdpSocket for AutoSackVirtualUdpSocket {
fn local_addr(&self) -> io::Result<SocketAddr> {
@@ -1301,6 +1477,7 @@ async fn udp_session_recv_loop_error_closes_registered_sessions() {
classified_sessions.clone(),
classified_accepts,
pending_connects.clone(),
Arc::new(DashMap::new()),
mux_accepted_tx,
control_tx,
Arc::new(NoopUdpSessionStunResponder),
@@ -1624,6 +1801,7 @@ async fn udp_session_recv_loop_does_not_wait_for_stun_responder() {
classified_sessions,
classified_accepts,
pending_connects,
Arc::new(DashMap::new()),
mux_accepted_tx,
control_tx,
stun_responder.clone(),
+65 -37
View File
@@ -15,23 +15,24 @@ impl<T: Buf> BufList<T> {
}
}
#[inline]
#[inline(always)]
pub fn push(&mut self, buf: T) {
debug_assert!(buf.has_remaining());
self.bufs.push_back(buf);
if buf.has_remaining() {
self.bufs.push_back(buf);
}
}
#[inline]
#[inline(always)]
pub fn pop(&mut self) -> Option<T> {
self.bufs.pop_front()
}
#[inline]
#[inline(always)]
pub fn len(&self) -> usize {
self.bufs.len()
}
#[inline]
#[inline(always)]
pub fn is_empty(&self) -> bool {
self.bufs.is_empty()
}
@@ -39,17 +40,15 @@ impl<T: Buf> BufList<T> {
impl<T: Buf> Extend<T> for BufList<T> {
fn extend<I: IntoIterator<Item = T>>(&mut self, iter: I) {
self.bufs.extend(
iter.into_iter()
.inspect(|buf| debug_assert!(buf.has_remaining())),
);
self.bufs
.extend(iter.into_iter().filter(Buf::has_remaining));
}
}
impl<T: Buf> Buf for BufList<T> {
#[inline]
fn remaining(&self) -> usize {
self.bufs.iter().map(|buf| buf.remaining()).sum()
self.bufs.iter().map(Buf::remaining).sum()
}
#[inline]
@@ -59,33 +58,30 @@ impl<T: Buf> Buf for BufList<T> {
#[inline]
fn chunks_vectored<'t>(&'t self, dst: &mut [IoSlice<'t>]) -> usize {
if dst.is_empty() {
return 0;
}
let mut vecs = 0;
for buf in &self.bufs {
vecs += buf.chunks_vectored(&mut dst[vecs..]);
if vecs == dst.len() {
let n = buf.chunks_vectored(&mut dst[vecs..]);
vecs += n;
if dst[vecs - n..vecs].iter().map(|s| s.len()).sum::<usize>() < buf.remaining() {
break;
}
}
vecs
}
#[inline]
fn advance(&mut self, mut cnt: usize) {
while cnt > 0 {
{
let front = &mut self.bufs[0];
let rem = front.remaining();
if rem > cnt {
front.advance(cnt);
return;
} else {
front.advance(rem);
cnt -= rem;
}
let front = &mut self.bufs[0];
let rem = front.remaining();
front.advance(cnt.min(rem));
if rem > cnt {
return;
}
cnt -= rem;
self.bufs.pop_front();
}
}
@@ -94,20 +90,21 @@ impl<T: Buf> Buf for BufList<T> {
fn copy_to_bytes(&mut self, len: usize) -> Bytes {
// Our inner buffer may have an optimized version of copy_to_bytes, and if the whole
// request can be fulfilled by the front buffer, we can take advantage.
match self.bufs.front_mut() {
Some(front) if front.remaining() == len => {
let b = front.copy_to_bytes(len);
if let Some(front) = self.bufs.front_mut()
&& let rem = front.remaining()
&& len <= rem
{
let bytes = front.copy_to_bytes(len);
if len == rem {
self.bufs.pop_front();
b
}
Some(front) if front.remaining() > len => front.copy_to_bytes(len),
_ => {
assert!(len <= self.remaining(), "`len` greater than remaining");
let mut bm = BytesMut::with_capacity(len);
bm.put(self.take(len));
bm.freeze()
}
return bytes;
}
assert!(len <= self.remaining());
let mut bytes = BytesMut::with_capacity(len);
bytes.put(self.take(len));
bytes.freeze()
}
}
@@ -125,4 +122,35 @@ mod tests {
assert_eq!(&bytes[..], b"hello world");
assert_eq!(list.remaining(), 0);
}
#[test]
fn test_chunks_vectored_partial_inner_buf() {
#[derive(Debug)]
struct PartialBuf(&'static [u8]);
impl Buf for PartialBuf {
fn remaining(&self) -> usize {
self.0.len()
}
fn chunk(&self) -> &[u8] {
&self.0[..self.0.len().min(1)]
}
fn advance(&mut self, cnt: usize) {
self.0 = &self.0[cnt..];
}
}
let mut list = BufList::new();
list.push(PartialBuf(b"ab"));
list.push(PartialBuf(b"c"));
let mut dst = [IoSlice::new(&[]); 3];
let n = list.chunks_vectored(&mut dst);
assert_eq!(n, 1);
assert_eq!(&*dst[0], b"a");
}
}
+7 -7
View File
@@ -1,4 +1,4 @@
use aes_gcm::{AeadInOut, Aes128Gcm, Aes256Gcm, Key, KeyInit};
use aes_gcm::{AeadInPlace, Aes128Gcm, Aes256Gcm, Key, KeyInit};
use rand::{RngCore, rngs::OsRng};
use zerocopy::{AsBytes, FromBytes};
@@ -54,16 +54,16 @@ impl Encryptor for AesGcmCipher {
let tag = aes_tail.tag.into();
let rs = match &self.cipher {
AesGcmEnum::AES128GCM(aes_gcm) => aes_gcm.decrypt_inout_detached(
AesGcmEnum::AES128GCM(aes_gcm) => aes_gcm.decrypt_in_place_detached(
&nonce,
&[],
(&mut zc_packet.mut_payload()[..text_len]).into(),
&mut zc_packet.mut_payload()[..text_len],
&tag,
),
AesGcmEnum::AES256GCM(aes_gcm) => aes_gcm.decrypt_inout_detached(
AesGcmEnum::AES256GCM(aes_gcm) => aes_gcm.decrypt_in_place_detached(
&nonce,
&[],
(&mut zc_packet.mut_payload()[..text_len]).into(),
&mut zc_packet.mut_payload()[..text_len],
&tag,
),
};
@@ -113,7 +113,7 @@ impl Encryptor for AesGcmCipher {
nonce.into()
});
(
aes_gcm.encrypt_inout_detached(&nonce, &[], zc_packet.mut_payload().into()),
aes_gcm.encrypt_in_place_detached(&nonce, &[], zc_packet.mut_payload()),
nonce,
)
}
@@ -124,7 +124,7 @@ impl Encryptor for AesGcmCipher {
nonce.into()
});
(
aes_gcm.encrypt_inout_detached(&nonce, &[], zc_packet.mut_payload().into()),
aes_gcm.encrypt_in_place_detached(&nonce, &[], zc_packet.mut_payload()),
nonce,
)
}

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