Compare commits

..
Author SHA1 Message Date
KKRainbow 3ed7fcece6 fix(windows): preserve Wintun identity across restarts (#2773)
* fix(windows): preserve Wintun identity across restarts

Update rust-tun to wait for Wintun orphan cleanup before recreating a
requested adapter GUID. This fixes intermittent device creation failures
after forced termination while keeping the same network identity.

The dependency change also handles WOW64 without a separate device
removal program and rejects adapters with mismatched identities.
2026-10-28 23:55:37 +08:00
Alrcatraz 4bdba58ca9 feat(web): enable tld_dns_zone end-to-end from Web Console to MagicDNS (#2425) 2026-10-28 20:37:54 +08:00
6487a5e463 fix(peers): don't report unmeasured connections as 1 ms to peer center (#2768)
* fix(peers): don't report unmeasured connections as 1 ms to peer center

A freshly established connection has no latency sample yet, so its
WindowLatency reports 0 and direct_peer_info advertised the peer as
max(1, 0) = 1 ms. With latency_first enabled, every node could then
route through a slow or lossy link until the next report (up to ~60 s),
and a flapping link re-advertised 1 ms on every reconnect.

Skip connections that have no latency sample yet. A peer with no
measured connection is left out of the report; it is reported once its
first sample arrives, because the peer list changes then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci(mobile): use the Android command-line tools build ID

setup-android expects a download build ID, not the displayed tools
version. The value 12.0 becomes 12 and produces a URL that returns 404
when the runner cannot reuse a preinstalled SDK.

Request build 11076708, which provides the same command-line tools
12.0. Keep the runner and other tool versions unchanged.

Validate the workflow YAML and confirm the archive URL returns HTTP 200.

---------

Co-authored-by: imoulei <188999141+imoulei@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: KKRainbow <5665404+KKRainbow@users.noreply.github.com>
2026-10-28 12:27:23 +08:00
KKRainbow 48ccc56a1a test(ipv6): reuse the TUN registry across client reconnects (#2765)
The public IPv6 reconnect fixture kept its process runtime but created
a fresh shared-TUN registry for each client. A replacement could open
the same named device while the old registry's asynchronous cleanup
still owned it, failing with EBUSY in CI.

Keep one registry in PublicIpv6Lab and use it for initial and replacement
instances, matching NativeInstanceFactory. Existing registration IDs
and the shared NIC mutex coordinate cleanup and replacement. Preserve
the lease, route, and ping assertions without changing their timeouts.

Validate with 20 reconnect stress runs, 4 public IPv6 cases, 7 shared-TUN
integration cases, formatting, and strict workspace clippy. The baseline
also passed 30 local runs, so this is not a deterministic local red/green
reproduction. Runtime stop semantics are unchanged.
2026-10-27 00:39:04 +08:00
fanyang b974acd737 perf(common): cache interface snapshots on the Windows bind path (#2744)
* perf(common): cache interface snapshots on the Windows bind path

Each bind with BindDev::Auto resolved the interface name via a full
NetworkInterface::show() enumeration, and on Windows setup_socket_for_win
then resolved the same name to an index through a second enumeration.
With many adapters (VMware / Hyper-V / APIPA leftovers are common on
Windows), one connect round turned into O(candidates) synchronous
enumerations on the async worker: issue #2735 measured ~4.9s of
candidate preparation against a 2s connect budget, and even on a healthy
8-adapter machine the loop cost ~124ms for 10 candidates.

Reuse a snapshot of the interface list with a 1s TTL in both places.
Measured on an 11-candidate machine: candidate bind loop drops from
~146ms to ~0.5ms; on machines with slow enumeration the win is
proportionally larger, keeping candidate preparation inside the connect
budget. Complements #2736, which offloads the remaining syscalls to the
blocking pool.

* fix(network): coalesce interface refreshes and limit caching to Windows

* fix(windows): isolate bind cache and correct lookup diagnostics
2026-10-27 00:38:53 +08:00
FaandKKRainbow 3463190838 fix(dataplane): finish TCP teardown after stream close (#2754)
Dropping a data-plane TCP stream removed its socket without sending FIN,
leaving peer reads and exit-node forwarding tasks waiting indefinitely.

Transfer released smoltcp sockets, flow registrations, and stack leases
to the existing reactor. Preserve FIN retransmissions and TIME-WAIT
after application handles close, drain unread peer data, and reclaim
sockets on completion, an absolute close deadline, or reactor shutdown.

Reuse session resource closure to cancel pending reads and writes. Keep
the Go host on the existing resource-close ABI and refresh its embedded
WASI guest, protobuf bindings, and matching provenance together.

Cover blocked writes, expired deadlines, lost FIN and ACK packets, close
deadlines, reactor shutdown, and final flow and stack lease release.
Validate with 77 Rust tests, the Go suite, fmt, and strict workspace
clippy.

Co-authored-by: KKRainbow <5665404+KKRainbow@users.noreply.github.com>
2026-10-27 00:32:23 +08:00
KKRainbow cf6531b302 fix(peer): keep active IP flows on their selected connection (#2753)
Periodic minimum-RTT reselection can move a busy UDP stream 
to an idle TCP connection and back as their queueing delays 
change. Bind active unfragmented TCP/UDP flows to their selected
connection so the five-second reselection applies to new flows and 
unkeyed packets.
2026-10-26 23:31:06 +08:00
KKRainbow 4ae9deb72b fix(net): recover peer, Web, and WireGuard portal listeners (#2760)
Keep peer listener reconstruction alive after runtime bind failures.
Withdraw failed bindings from the running registry before retrying.
Web configuration listeners retry listen after accept errors with a
fixed 100 ms delay, until their owner is stopped.

Make TCP and UDP listen replace the old binding while retaining the
assigned port. Keep accept ownership unchanged for other consumers.
Release closed UDP sessions' layer references and wake classified
accepts on receive shutdown so peer listeners can be reconstructed.

Let the existing WireGuard Portal reader replace its socket through a
shared endpoint slot, retaining the engine and client sessions. Discard
oversized Windows UDP datagrams before they reach listener recovery.

Cover repeated binding failure, cancellation, fixed ports, old socket
release, classified shutdown ordering, and successful Web recovery.
2026-10-26 21:34:50 +08:00
KKRainbow 6a5507b41e fix(icmp): open proxy sockets on demand and activate dynamic subnets (#2759)
* fix(icmp): open proxy sockets only when forwarding echo requests

Keep local no-TUN echo replies independent of raw socket permissions.
Create the socket and receiver on the first outward request, so newly
added proxy networks and exit-node traffic work without eager sockets.

Serialize concurrent initialization and socket publication with stop.
Roll back partial resources on failure or cancellation, and retain an
opened socket until shutdown for in-flight replies and later requests.

Cover local replies, dynamic CIDR updates, concurrent first requests,
exit-node forwarding, failed opens, cancellation and shutdown.

* fix(proxy): start forwarding after the first dynamic subnet update

A TUN instance started without proxy networks skips the packet proxy.
Runtime updates previously published new CIDRs without starting it, so
the first dynamically added subnet could not forward packets.

Reapply the existing startup predicate after configuration publication.
Run proxy startup on the instance executor so its sockets and tasks
survive a temporary configuration caller. Roll back partial startup on
cancellation as well as errors, allowing a subsequent update to retry.

Cover first-subnet forwarding, mapped CIDRs, removal and re-addition,
system forwarding, bind failure, cancellation and executor ownership.
2026-10-25 23:29:37 +08:00
KKRainbow dccb85ab36 feat(core): add an opt-in shared runtime for native instances (#2757)
* feat(core): share an instance runtime with CLI worker control

Add --shared-runtime to easytier-core and reuse --multi-thread-count.
Default shared workers to available CPUs while retaining dedicated
runtime defaults. Consume shared runtime flags at process scope so
config files remain writable and keep their own thread settings.

Share executor ownership across the factory and its instances, keeping
normal instance capabilities and independent shutdown paths. Cover
worker selection, instance deletion, hot addition, and final teardown.

* docs: document shared native runtime ownership

Describe the opt-in shared executor alongside the default per-instance model, including worker selection and last-owner shutdown semantics.
2026-10-25 22:46:11 +08:00
KKRainbow 4a91eeb379 fix(icmp): drive raw sockets with asynchronous readiness (#2756)
Register nonblocking raw ICMP sockets with Tokio instead of creating
a blocking task for every receive. Keep the raw packet layout and
network namespace binding unchanged across native platforms.

Cancel pending sends and receives when the socket closes, without
relying on shutdown of an unconnected raw socket.

Cover receive cancellation, closing multiple waiters, and repeated
loopback echo fanout across 30 raw sockets without blocking workers.
2026-10-25 21:45:05 +08:00
79dd97dd7f fix(wasi): advertise the compiled smoltcp stack so subnet proxy can forward (#2734)
* fix(wasi): advertise the compiled smoltcp stack and keep ring out of the guest

A WASI guest has no OS TUN device, so the TCP subnet proxy can only forward
through the user-space stack. The capability probe in the WASI runtime only
set smoltcp_available under wasm-host-tunnel-outbound, so a plain core build
left force_smoltcp false and the proxy fell back to the kernel-listener path,
which rewrites the SYN onto a device that does not exist and lets every entry
expire as "syn nat entry expired".

- Add CoreInstanceHostConfig::set_smoltcp_stack and call it from the WASI
  runtime whenever the stack is compiled in. The setter only raises the flag,
  so the host-tunnel mode keeps working independently of the feature.
- wasi-crypto-offload no longer pulls ring-crypto: the host performs AEAD for
  the guest (easytier_host.crypto_aead_seal/open), and a software backend adds
  no value inside WASI.
- build-wasi-core.sh now builds with --no-default-features and an explicit
  list. endpoint-discovery was the only default that pulled rustls/ring; ring
  compiles C shims for wasm32-wasip1 that surface as env.ring_core_* imports,
  which the Go host cannot provide.

Covered by two unit tests for the setter.

* style(wasi): remove extra blank line from runtime module

Remove the extra blank line at the end of the WASI runtime module.
This restores the repository-wide cargo fmt check for PR #2734.

---------

Co-authored-by: 刘金发 <liujinfa9242@dingtalk.com>
Co-authored-by: KKRainbow <5665404+KKRainbow@users.noreply.github.com>
2026-10-25 19:34:33 +08:00
fanyang 2eca40c56a fix(proto): set PROTOC_INCLUDE when sourcing protoc on Windows (#2743) 2026-10-25 18:15:37 +08:00
fe18f32c8b fix(peer_manager): 按 NIC 过滤器改写后的目的地址路由 (#2740)
* fix(peer_manager): route by destination rewritten in the nic packet pipeline

NIC packet filters receive &mut ZCPacket and may rewrite the IP header (e.g. a NAT filter that maps a virtual peer address to an address on the local subnet). send_msg_by_ip parsed the destination before running the filter pipeline and then routed by that stale address, so rewritten packets were looked up by the pre-filter destination and dropped as unreachable.

Re-parse the destination after run_nic_packet_process_pipeline and route by the rewritten address. Expose instance::packet_io as pub(crate) so peers can reuse the parser.

* test(peer-manager): cover NIC destination rewrite routing

Exercise IPv4 and IPv6 delivery through three peers connected by memory
tunnels. A NIC filter rewrites the destination without setting a peer
ID; verify delivery to the rewritten peer and no delivery to the
original.

Cover original destinations with and without an existing route. Wait for
route convergence before sending each packet once, without data retries.

Both regression tests fail with the PR routing fix removed and pass with
it restored. All 37 peer-manager tests and formatting checks pass.
Scoped Clippy completes with warnings in pre-existing code.

---------

Co-authored-by: ldoubil <ldoubil@users.noreply.github.com>
Co-authored-by: KKRainbow <5665404+KKRainbow@users.noreply.github.com>
2026-10-25 13:13:32 +08:00
KKRainbow 5565251562 test(limiter): stop racing the token bucket refill clock (#2752)
* test(limiter): stop racing the token bucket refill clock

test_initial_state drains a 1000 byte bucket that refills at 1000
bytes/s and then asserts that one more token is rejected. try_consume
refills from Instant::now() first, so the assertion only holds while
the thread runs within one millisecond of the drain; one millisecond
of scheduling delay is enough for the token to appear.

Freeze the lazy refill by moving last_refill into the future so no
token can appear between the two calls, and apply the same freeze to
test_consumption, which had the same race with a 100 ms window.

* test(limiter): pass the refill instant explicitly instead of faking the clock

Moving last_refill into the future only widened the window: once that
offset passes the bucket refills again, so the tests still depended on
the wall clock.

Split the locked consumption into try_consume_locked(state, tokens, now)
and have production take the lock first, then read the clock. Both
tests take one instant and reuse it for every call, so no refill can
happen between the assertions.
2026-10-25 12:57:13 +08:00
minc_nice_100andminc-nice-100 ffcfba4afd fix(deps): bump rustls stack for eMudhra CA distrust + rustls 0.23.46 advisories (#2751)
- rustls 0.23.45 -> 0.23.46 (RUSTSEC-2026-0346, -0347, -0348, -0349)
- rustls-webpki 0.103.15 -> 0.103.16
- webpki-roots 1.0.9 -> 1.0.11
- webpki-root-certs 1.0.9 -> 1.0.11

  RUSTSEC-2026-0381 / RUSTSEC-2026-0382 / RUSTSEC-2026-0383,
  CVE-2026-111943: compromised eMudhra emSign intermediate CA keys.
  Disclosure: https://emudhra.we-have-a-problem.com/
  Mozilla bug 2083449.

- zip 8.6.0 audited against RUSTSEC-2026-0367 / -0368 / -0369: no
  ZipArchive::extract / ZipStreamReader::extract usage in this repo,
  vulnerable extraction paths not reachable, no bump needed.

Cargo.lock only; no source changes.

Co-authored-by: minc-nice-100 <minc-nice-100@users.noreply.github.com>
2026-10-25 12:55:20 +08:00
KKRainbow 7f2e9dddf2 test(three_node): name the gate that timed out (#2749)
foreign_network_advertises_listeners_for_direct_connect waits on four
separate conditions, and wait_for_condition panics with a bare
"Timeout" from inside the helper. A red CI run therefore never says
which gate failed, which is what made the last recurrence on
fix/2739-platform-verifier hard to attribute.

Add wait_for_condition_named and use it for the four gates so the
panic names the condition. No behavior change: same polling
interval, same 10s budget.
2026-10-25 12:54:49 +08:00
KKRainbow 887c701ee8 test(connector): fix foreign listener discovery readiness and isolation (#2750)
* test(connector): label the convergence waits of the direct connect test

foreign_network_advertises_listeners_for_direct_connect failed in run
39903378976 with a bare "Timeout" from wait_for_condition, so the log
cannot tell which convergence gate was hit: each of the two node pairs
has four waits (remote sees client-net, client learns the route,
remote learns the client peer, direct connection established).

Add wait_for_condition_with_reason and use it for all four waits, with
the node pair and the step in the message. Timeouts and assertions are
unchanged.

* test(connector): wait for the reply route the router actually uses

The reverse-route wait added in #2738 checked the foreign network peer
list, which only holds direct connections. The packet router also
routes through the route table, so the wait could time out although a
reply would have been routed, and a direct connection often appears
only later in the flow.

Call the router lookup (get_gateway_peer_id for the client peer on the
remote foreign network) through a test accessor and wait until any
gateway is available.

CI run 39911533854 timed out on the previous condition for both tcp
and udp.

* test(connector): wait for the reply route the router actually uses

The reverse-route wait added in #2738 checked the foreign network peer
list, which only holds direct connections. The packet router routes
through the route table as well (get_gateway_peer_id), so the wait
could time out although a reply would have been routed, and a direct
connection often appears only later in the flow.

Add a test accessor that performs the router lookup on the remote
foreign network and wait until any gateway is available.

Drops the gate labels from this branch, they are covered by #2749.

CI run 39911533854 timed out on the previous condition for both tcp
and udp.

* test(connector): report remote routing state when get_ip_list times out

CI run 39921692334 failed after the gateway wait succeeded: the RPC
itself timed out with all waits green, so the gateway was available
but the reply still did not arrive.

On RPC error, print the remote client-net state next to the error:
the gateway chosen for the client, whether that gateway is a direct
peer, and the peer ids currently connected. This makes the next
failure attributable without changing any assertion or timeout.

* test(connector): also report the origin mapping of the chosen gateway

A wrapped foreign network packet is forwarded by mapping the gateway
foreign peer id to its origin (parent network) peer id and sending it
through the parent peer map, so a gateway that is not directly
connected does not by itself mean the reply is dropped.

Extend the RPC failure snapshot with get_origin_my_peer_id for the
chosen gateway and whether the parent peer map can route to that
origin id.

* test(connector): capture the foreign network forwarding decisions

The routing snapshot taken after the timeout cannot explain a lost RPC
reply: it is sampled after the timeout, re-queries the state (which may
converge later) and does not show whether the request reached the
remote at all.

Install a test-scoped tracing subscriber that keeps debug events from
easytier_core::peers, so a failing run prints the forwarding decisions
(dropped relay data, missing origin peer id, failed direct send) when
they happen. Only this test process is affected.

* test(connector): isolate foreign listener discovery

The foreign DirectConnectorRpc handler collects interface addresses in
the default network namespace. Although the three-node fixture gives
each instance its own namespace, the RPC still observes host bridges
and veth interfaces created and removed by concurrent tests.

Add both protocol cases to the existing exclusive override for host
interfaces. Preserve the reply route readiness check, RPC deadline,
listener assertion, and final direct connection assertion.

Diagnostics with 32 test threads on four CPUs showed interface
enumeration delaying the RPC handler by over two seconds. With this
override, the same mixed workload kept all observed handlers below
1.1 ms.

Remove the temporary routing snapshot accessors, failure dump, and
test tracing setup. Keep only the helpers used by the readiness check.
2026-10-25 11:49:06 +08:00
KKRainbow 82dd835c26 feat(tls): make system certificates opt-in for HTTPS discovery (#2748) 2026-10-24 04:06:58 +08:00
KKRainbow 411faae131 test(limiter): assert bps limits on metered bytes (#2741)
The relay and instance-receive rate limit tests compared TCP
application payload against the configured limit, but the limiter
charges EasyTier payload including the inner IP and transport
headers. That left the 800 KiB/s case only about 2% above its lower
bound, so ordinary variance pushed it under (717 vs 720).

Count the bytes each limiter charges and assert that rate within 5%
instead. TokenBucket now tracks metered bytes, exposed through the
limiter trait and hidden test accessors, and the benchmark samples
the counter once the initial burst is drained. Keep the payload
upper bound, which the encapsulation mismatch does not invalidate,
so a limiter whose rejection the forwarding path ignores still
fails the test.
2026-10-23 18:00:58 +08:00
KKRainbow 6d9adc4fb9 fix(core): expose smoltcp feature for auditable builds (#2747)
Cargo exposes an enabled optional dependency as a rustc cfg even when
dep:smoltcp hides the implicit feature. cargo-auditable later passes
that cfg to cargo metadata, which rejects it.

Make smoltcp a core feature that selects proxy-packet, including the
dependency settings required when enabled on its own.

Fixes #2745
2026-10-23 13:51:04 +08:00
KKRainbow 54f2850d9d test(connector): wait for reply route in foreign network direct connect (#2738) 2026-10-22 08:42:03 +08:00
KKRainbow e307dadb48 fix(connectivity): advertise shared-node running listeners (#2733) 2026-10-21 09:47:03 +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
277 changed files with 29752 additions and 5592 deletions
+26
View File
@@ -3,3 +3,29 @@ 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.
# Foreign-network listener discovery also enumerates the default namespace.
# 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) | test(tests::three_node::foreign_network_advertises_listeners_for_direct_connect)'
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
+23 -19
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
@@ -98,7 +94,7 @@ jobs:
- name: Setup Android SDK
uses: android-actions/setup-android@v3
with:
cmdline-tools-version: 12.0
cmdline-tools-version: '11076708'
packages: 'build-tools;34.0.0 ndk;26.0.10792818 platform-tools platforms;android-34 '
- name: Setup Android Environment
@@ -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
+117 -143
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"
@@ -230,7 +216,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -241,7 +227,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -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"
@@ -2388,7 +2365,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.3",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -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,17 @@ 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",
"rustls-platform-verifier",
"serde",
"serde_json",
"sha2 0.11.0",
"sha2 0.10.9",
"smoltcp",
"snow",
"strum 0.28.0",
@@ -2720,7 +2702,7 @@ dependencies = [
"url",
"uuid",
"wasm-bindgen",
"webpki-roots 1.0.9",
"webpki-roots 1.0.11",
"wildmatch",
"x25519-dalek 2.0.1",
"zerocopy 0.7.35",
@@ -2811,12 +2793,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 +2840,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 +2909,7 @@ dependencies = [
"axum-embed",
"axum-login",
"axum-messages",
"base64 0.23.1",
"base64 0.22.1",
"chrono",
"cidr",
"clap",
@@ -2937,18 +2922,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 +2943,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 +3013,7 @@ dependencies = [
"ff",
"generic-array",
"group",
"hkdf 0.12.4",
"hkdf",
"pem-rfc7468",
"pkcs8",
"rand_core 0.6.4",
@@ -3232,7 +3220,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -3778,16 +3766,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 +4276,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"
@@ -5538,7 +5508,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.20",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -5940,7 +5910,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -6070,7 +6040,6 @@ dependencies = [
"getrandom 0.2.17",
"http",
"rand 0.8.8",
"reqwest 0.12.28",
"serde",
"serde_json",
"serde_path_to_error",
@@ -6079,6 +6048,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 +6393,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"
@@ -6474,7 +6477,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.45.0",
]
[[package]]
@@ -7001,17 +7004,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 +7281,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"
@@ -7465,7 +7467,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -7743,38 +7745,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"
@@ -8089,14 +8059,14 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
name = "rustls"
version = "0.23.45"
version = "0.23.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
dependencies = [
"aws-lc-rs",
"once_cell",
@@ -8147,7 +8117,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -8158,9 +8128,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.15"
version = "0.103.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
dependencies = [
"aws-lc-rs",
"ring",
@@ -8788,6 +8758,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 +8955,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",
@@ -8997,7 +8970,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -9182,7 +9155,7 @@ dependencies = [
"futures-util",
"generic-array",
"hex",
"hkdf 0.12.4",
"hkdf",
"hmac 0.12.1",
"itoa",
"log",
@@ -9225,7 +9198,7 @@ dependencies = [
"futures-core",
"futures-util",
"hex",
"hkdf 0.12.4",
"hkdf",
"hmac 0.12.1",
"home",
"itoa",
@@ -9633,7 +9606,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest 0.13.5",
"reqwest",
"serde",
"serde_json",
"serde_repr",
@@ -9929,10 +9902,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -9951,7 +9924,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
dependencies = [
"rustix 1.1.5",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -10217,7 +10190,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tokio-util",
"webpki-roots 1.0.9",
"webpki-roots 1.0.11",
]
[[package]]
@@ -10635,7 +10608,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.20",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -10675,7 +10648,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=ac0c674c900dbdcf0b3fe9f5f6134c11579d1f7c#ac0c674c900dbdcf0b3fe9f5f6134c11579d1f7c"
dependencies = [
"bytes",
"cfg-if",
@@ -10742,7 +10715,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -10916,6 +10889,7 @@ dependencies = [
"js-sys",
"rand 0.10.2",
"serde_core",
"sha1_smol",
"wasm-bindgen",
]
@@ -11248,9 +11222,9 @@ dependencies = [
[[package]]
name = "webpki-root-certs"
version = "1.0.9"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a"
dependencies = [
"rustls-pki-types",
]
@@ -11261,14 +11235,14 @@ version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.9",
"webpki-roots 1.0.11",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171"
dependencies = [
"rustls-pki-types",
]
@@ -11387,7 +11361,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.48.0",
]
[[package]]
@@ -12012,7 +11986,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [
"cfg-if",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
+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)
+130
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,19 @@ A core instance:
`CoreProcessRuntime`, plus optional protocol and platform capabilities. The
bundle carries capabilities, not preconstructed portable managers.
By default, 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. The `easytier-core --shared-runtime` option instead shares one
multi-thread executor across the factory and all its instances. Its worker count
defaults to the available CPU count and can be set with the process-level
`--multi-thread-count` option, with a minimum of one; per-instance runtime flags
are ignored in this mode. Construction, start/stop, and FFI data-plane tasks use
the selected executor, independent of the process management runtime. Dropping
the last factory or instance 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 +418,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 +507,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 +557,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(),
)
}
+38 -11
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 }
@@ -54,10 +55,19 @@ rand.workspace = true
quanta.workspace = true
ring = { version = "0.17", optional = true }
rustls = { workspace = true, features = ["ring", "std", "tls12"], optional = true }
rustls-platform-verifier = { version = "0.7", optional = true }
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 +84,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"]
@@ -100,22 +110,31 @@ browser-config = ["config-write", "easytier-proto/api", "easytier-proto/json-rpc
chacha20 = ["dep:chacha20poly1305"]
openssl-crypto = ["dep:openssl"]
ring-crypto = ["dep:ring"]
wasi-crypto-offload = ["ring-crypto"]
# The WASI host performs AEAD for the guest (easytier_host.crypto_aead_seal/open),
# so the guest must not link a software backend: ring would emit env.ring_core_* imports
# that the host module cannot provide.
wasi-crypto-offload = []
config-write = []
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",
]
# Opt in to platform trust for HTTPS discovery; excluded from default features.
system-certificates = ["endpoint-discovery", "dep:rustls-platform-verifier"]
dhcp-ipv4 = []
dhcp-ipv6 = []
public-ipv6-provider = []
vpn-portal = []
wrapped-transport = []
extended-services = [
"dhcp-ipv4",
"dhcp-ipv6",
"public-ipv6-provider",
"vpn-portal",
"wrapped-transport",
@@ -127,6 +146,7 @@ management-rpc = ["easytier-proto/api"]
proxy-cidr-monitor = []
rich-config-errors = ["dep:ariadne"]
tcp-hole-punch = []
smoltcp = ["proxy-packet"]
proxy-packet = [
"wrapped-transport",
"dep:smoltcp",
@@ -152,12 +172,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
}
+495 -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,51 @@ 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.tld_dns_zone = flags.tld_dns_zone;
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 +122,7 @@ const FORM_MANAGED_TOML_FIELDS: &[&str] = &[
"ipv6_public_addr_auto",
"ipv6_public_addr_prefix",
"dhcp",
"dhcpv6",
"network_identity",
"listeners",
"mapped_listeners",
@@ -112,47 +139,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 +170,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 +252,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 +456,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 +477,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 +495,60 @@ 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,
tld_dns_zone: self.tld_dns_zone.clone(),
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 +565,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 +655,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 +682,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 +696,7 @@ mod tests {
#![allow(deprecated)]
use super::*;
use easytier_proto::common::Flags;
fn api_portal_config() -> manage::VpnPortalConfig {
manage::VpnPortalConfig {
@@ -853,6 +718,234 @@ 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 magic_dns_zone_round_trips_missing_default_and_custom_values() {
for zone in [None, Some("et.net."), Some("nas.internal.")] {
let input = NetworkConfig {
tld_dns_zone: zone.map(str::to_owned),
..standalone_config()
};
let config = input.gen_config().unwrap();
let config = TomlConfigLoader::new_from_str(&config.dump()).unwrap();
assert_eq!(config.get_flags().tld_dns_zone, zone.unwrap_or("et.net."));
let output = NetworkConfig::new_from_config(&config).unwrap();
assert_eq!(output.tld_dns_zone.as_deref(), zone);
}
}
#[test]
fn vpn_portal_api_config_round_trips_through_toml_model() {
let input = NetworkConfig {
@@ -969,6 +1062,62 @@ mod tests {
assert!(config.get_vpn_portal_config().is_none());
}
#[cfg(feature = "browser-config")]
#[test]
fn browser_merge_updates_and_clears_magic_dns_zone() {
let original = "[flags]\ntld_dns_zone = \"old.internal.\"\n";
for zone in [None, Some("et.net."), Some("nas.internal.")] {
let input = NetworkConfig {
tld_dns_zone: zone.map(str::to_owned),
..standalone_config()
};
let merged = merge_network_config_toml(original, &input).unwrap();
let output = TomlConfigLoader::new_from_str(&merged).unwrap();
assert_eq!(output.get_flags().tld_dns_zone, zone.unwrap_or("et.net."));
assert_eq!(output.get_flags_patch().tld_dns_zone.as_deref(), zone);
}
}
#[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 +1155,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())
);
}
}
@@ -723,6 +723,7 @@ mod tests {
));
let handler = DirectConnectorRpcHandler::new_for_foreign_network_with_stun(
host,
Arc::new(crate::connectivity::NoLocalListeners),
SocketContext::default().with_socket_mark(Some(7)),
Some(Arc::new(FixedStunProvider)),
);
@@ -744,6 +745,14 @@ mod tests {
response.public_ipv4,
Some("198.51.100.7".parse::<std::net::Ipv4Addr>().unwrap().into())
);
assert_eq!(
response
.listeners
.into_iter()
.map(Url::from)
.collect::<Vec<_>>(),
vec!["tcp://192.0.2.1:11010".parse::<Url>().unwrap()]
);
}
fn snapshot() -> HostConnectorEnvironmentSnapshot {
+15 -4
View File
@@ -18,7 +18,7 @@ use crate::{
connectivity::hole_punch::policy::{should_background_p2p_with_peer, should_try_p2p_with_peer},
connectivity::stun::{StunInfoProvider, StunSocketMapper},
connectivity::{
LocalListenerUrls, NoLocalListeners,
LocalListenerUrls,
protocol::{
ClientProtocolUpgrader, ProtocolTransport, protocol_transport, protocol_uses_udp,
},
@@ -915,10 +915,10 @@ where
{
pub fn new_for_foreign_network_with_stun(
host: Arc<H>,
running_listeners: Arc<dyn LocalListenerUrls>,
socket_context: SocketContext,
stun: Option<Arc<dyn StunInfoProvider>>,
) -> Self {
let running_listeners = Arc::new(NoLocalListeners);
Self {
host,
peer_manager: None,
@@ -997,14 +997,23 @@ where
{
host: Arc<H>,
stun: Arc<dyn StunInfoProvider>,
running_listeners: Arc<dyn LocalListenerUrls>,
}
impl<H> ForeignDirectConnectorRpcRegistrar<H>
where
H: DirectConnectorHost,
{
pub fn new(host: Arc<H>, stun: Arc<dyn StunInfoProvider>) -> Self {
Self { host, stun }
pub fn new(
host: Arc<H>,
stun: Arc<dyn StunInfoProvider>,
running_listeners: Arc<dyn LocalListenerUrls>,
) -> Self {
Self {
host,
stun,
running_listeners,
}
}
}
@@ -1022,6 +1031,7 @@ where
GeneratedDirectConnectorRpcServer::new(
DirectConnectorRpcHandler::new_for_foreign_network_with_stun(
self.host.clone(),
self.running_listeners.clone(),
socket_context,
Some(self.stun.clone()),
),
@@ -1201,6 +1211,7 @@ fn is_public_ipv4(ip: Ipv4Addr) -> bool {
#[cfg(test)]
mod tests {
use super::*;
use crate::connectivity::NoLocalListeners;
impl<H> DirectConnectorRpcHandler<H>
where
@@ -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(
@@ -7,6 +7,8 @@ use hyper::{Request, header};
use hyper_util::rt::TokioIo;
use rand::{Rng as _, seq::SliceRandom};
use rustls::pki_types::ServerName;
#[cfg(feature = "system-certificates")]
use rustls_platform_verifier::BuilderVerifierExt as _;
use tokio::io::{AsyncRead, AsyncWrite};
use tokio_rustls::TlsConnector;
use tokio_util::task::AbortOnDropHandle;
@@ -184,12 +186,23 @@ where
if request.url.scheme() == "https" {
let server_name = tls_server_name(&request.url)?;
let root_store = rustls::RootCertStore {
// 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")?;
#[cfg(feature = "system-certificates")]
let tls_config = tls_config
.with_platform_verifier()
.context("loading platform TLS verifier failed")?;
#[cfg(not(feature = "system-certificates"))]
let tls_config = tls_config.with_root_certificates(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();
});
let tls_config = tls_config.with_no_client_auth();
let stream = TlsConnector::from(Arc::new(tls_config))
.connect(server_name, socket)
.await
@@ -631,6 +644,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
}
+41 -6
View File
@@ -12,6 +12,11 @@ use crate::foundation::time;
pub(crate) trait ByteLimiter: Send + Sync {
fn try_consume(&self, bytes: u64) -> bool;
/// Bytes charged by this limiter so far.
fn metered_bytes(&self) -> u64 {
0
}
}
impl ByteLimiter for () {
@@ -37,6 +42,7 @@ struct BucketState {
available_tokens: u64,
last_refill: Instant,
refill_remainder: u64,
metered_bytes: u64,
}
#[derive(Clone, Copy)]
@@ -78,6 +84,7 @@ impl TokenBucket {
available_tokens: config.capacity,
last_refill: Instant::now(),
refill_remainder: 0,
metered_bytes: 0,
}),
config,
stop_notifier: Notify::new(),
@@ -132,15 +139,30 @@ impl TokenBucket {
let tokens = tokens.min(self.config.capacity);
let mut state = self.state.lock();
self.refill(&mut state, Instant::now());
let now = Instant::now();
self.try_consume_locked(&mut state, tokens, now)
}
/// Consume from an already locked state at an explicit instant.
///
/// Production takes the lock first and then reads the clock, so tests can
/// pass the same instant twice to observe the bucket without any refill.
fn try_consume_locked(&self, state: &mut BucketState, tokens: u64, now: Instant) -> bool {
self.refill(state, now);
if state.available_tokens < tokens {
return false;
}
state.available_tokens -= tokens;
state.metered_bytes = state.metered_bytes.saturating_add(tokens);
true
}
/// Total bytes charged by this bucket so far.
pub fn metered_bytes(&self) -> u64 {
self.state.lock().metered_bytes
}
/// Consume tokens, sleeping until they become available.
pub async fn consume(&self, tokens: u64) {
let mut remaining = tokens;
@@ -166,6 +188,7 @@ impl TokenBucket {
self.refill(&mut state, Instant::now());
if state.available_tokens >= tokens {
state.available_tokens -= tokens;
state.metered_bytes = state.metered_bytes.saturating_add(tokens);
return;
}
@@ -195,6 +218,10 @@ impl ByteLimiter for TokenBucket {
fn try_consume(&self, bytes: u64) -> bool {
TokenBucket::try_consume(self, bytes)
}
fn metered_bytes(&self) -> u64 {
TokenBucket::metered_bytes(self)
}
}
pub struct TokenBucketManager {
@@ -295,10 +322,14 @@ mod tests {
#[tokio::test]
async fn test_initial_state() {
let bucket = TokenBucket::new(1000, 1000);
// Refill is time based; reuse one instant so no token can appear
// between the two calls regardless of scheduling.
let now = Instant::now();
let mut state = bucket.state.lock();
// Should have full capacity initially
assert!(bucket.try_consume(1000));
assert!(!bucket.try_consume(1)); // Should be empty now
assert!(bucket.try_consume_locked(&mut state, 1000, now));
assert!(!bucket.try_consume_locked(&mut state, 1, now)); // Should be empty now
}
/// Test token consumption behavior
@@ -306,14 +337,18 @@ mod tests {
async fn test_consumption() {
let bucket = TokenBucket::new(1500, 1000);
// Same instant for every call: no refill can happen between them.
let now = Instant::now();
let mut state = bucket.state.lock();
// First packet should succeed
assert!(bucket.try_consume(1000));
assert!(bucket.try_consume_locked(&mut state, 1000, now));
// Second packet should fail (only 500 left)
assert!(!bucket.try_consume(600));
assert!(!bucket.try_consume_locked(&mut state, 600, now));
// Should be able to take remaining tokens
assert!(bucket.try_consume(500));
assert!(bucket.try_consume_locked(&mut state, 500, now));
}
#[tokio::test]
+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);
+90 -54
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,12 +172,23 @@ pub(super) enum FlowData {
Udp,
}
const UDP_ENTRY: FlowKind = FlowKind::Udp;
const TCP_ENTRY: FlowKind = FlowKind::Tcp;
const TCP_LISTEN_ENTRY: FlowKind = FlowKind::TcpListen;
type FlowSet = Arc<FlowTable<FlowData>>;
type DataPlaneTcpIo = Box<dyn TcpProxyStream>;
pub(super) enum DataPlaneTcpIo {
Smoltcp(crate::gateway::smoltcp::TcpStream),
External(Box<dyn TcpProxyStream>),
}
impl DataPlaneTcpIo {
fn as_mut(&mut self) -> &mut dyn TcpProxyStream {
match self {
Self::Smoltcp(stream) => stream,
Self::External(stream) => &mut **stream,
}
}
}
pub(crate) struct DataPlaneRuntime<H>
where
@@ -195,6 +209,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 +222,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 +272,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 +286,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 +328,7 @@ where
),
}
None
PeerPacketFilterResult::Consumed
}
}
@@ -359,6 +360,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 +394,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 +440,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 +498,7 @@ where
select! {
_ = peer_changes.changed() => {}
_ = fragment_gc.tick() => {}
_ = data_plane_consumers.changed() => {}
}
}
@@ -549,6 +561,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) {
@@ -687,7 +700,7 @@ where
generation.ensure_open()?;
Ok(DataPlaneTcpStream::new(
Box::new(stream),
DataPlaneTcpIo::Smoltcp(stream),
local_addr,
Some(data_plane_ref),
DataPlaneTcpStreamRoute::Outbound { _flow: flow },
@@ -709,7 +722,7 @@ where
.await?;
let local_addr = socket.local_addr().map_err(DataPlaneError::from)?;
Ok(DataPlaneTcpStream::new(
Box::new(socket),
DataPlaneTcpIo::External(Box::new(socket)),
local_addr,
None,
DataPlaneTcpStreamRoute::External,
@@ -747,7 +760,7 @@ where
)))
.await?;
Ok(DataPlaneTcpStream::new(
stream,
DataPlaneTcpIo::External(stream),
source_addr,
None,
DataPlaneTcpStreamRoute::External,
@@ -943,19 +956,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 +1003,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()
}
+22 -7
View File
@@ -25,7 +25,7 @@ pub(super) enum DataPlaneTcpStreamRoute {
/// A TCP stream created by the data-plane API.
pub struct DataPlaneTcpStream {
pub(super) stream: DataPlaneTcpIo,
stream: Option<DataPlaneTcpIo>,
pub(super) local_addr: SocketAddr,
pub(super) _route: DataPlaneTcpStreamRoute,
pub(super) _data_plane_lease: Option<DataPlaneLease>,
@@ -53,7 +53,7 @@ impl DataPlaneTcpStream {
generation: DataPlaneIoGuard,
) -> Self {
Self {
stream,
stream: Some(stream),
local_addr,
_data_plane_lease: data_plane_lease,
_route: route,
@@ -68,6 +68,21 @@ impl DataPlaneTcpStream {
}
}
impl Drop for DataPlaneTcpStream {
fn drop(&mut self) {
if let Some(DataPlaneTcpIo::Smoltcp(stream)) = self.stream.take() {
if self.generation.is_closed() {
return;
}
// The application is done, but FIN retransmissions and the peer's
// FIN still need this flow and the network stack until TCP closes.
let route = std::mem::replace(&mut self._route, DataPlaneTcpStreamRoute::External);
let lease = self._data_plane_lease.take();
stream.close((route, lease));
}
}
}
impl DataPlaneTcpListener {
pub fn local_addr(&self) -> SocketAddr {
self.local_addr
@@ -99,7 +114,7 @@ impl DataPlaneTcpListener {
FlowData::DataPlaneRoute,
);
let accepted = DataPlaneTcpStream::new(
Box::new(stream),
DataPlaneTcpIo::Smoltcp(stream),
local_addr,
Some(self.data_plane_lease.clone()),
DataPlaneTcpStreamRoute::Accepted { _flow: flow },
@@ -119,7 +134,7 @@ impl AsyncRead for DataPlaneTcpStream {
if this.generation.ensure_open().is_err() || this.read_closed.as_mut().poll(cx).is_ready() {
return Poll::Ready(Err(this.generation.closed_io_error()));
}
Pin::new(&mut this.stream).poll_read(cx, buf)
Pin::new(this.stream.as_mut().unwrap().as_mut()).poll_read(cx, buf)
}
}
@@ -134,7 +149,7 @@ impl AsyncWrite for DataPlaneTcpStream {
{
return Poll::Ready(Err(this.generation.closed_io_error()));
}
Pin::new(&mut this.stream).poll_write(cx, buf)
Pin::new(this.stream.as_mut().unwrap().as_mut()).poll_write(cx, buf)
}
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), std::io::Error>> {
@@ -143,7 +158,7 @@ impl AsyncWrite for DataPlaneTcpStream {
{
return Poll::Ready(Err(this.generation.closed_io_error()));
}
Pin::new(&mut this.stream).poll_flush(cx)
Pin::new(this.stream.as_mut().unwrap().as_mut()).poll_flush(cx)
}
fn poll_shutdown(
@@ -155,6 +170,6 @@ impl AsyncWrite for DataPlaneTcpStream {
{
return Poll::Ready(Err(this.generation.closed_io_error()));
}
Pin::new(&mut this.stream).poll_shutdown(cx)
Pin::new(this.stream.as_mut().unwrap().as_mut()).poll_shutdown(cx)
}
}
+385 -133
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())
@@ -534,44 +542,66 @@ async fn listener_and_accepted_stream_own_independent_flow_lifetimes() {
assert_eq!(&buf, b"after-listener-drop");
drop(client);
assert_eq!(a.gateway.entries.count(), 0);
assert_eq!(a.gateway.entries.count(), 1);
assert_eq!(server.read(&mut buf).await.unwrap(), 0);
// The closed application no longer reads, but TCP must keep draining
// incoming data so the peer's queued data and FIN can finish sending.
tokio::time::timeout(timeout, async {
server.write_all(&vec![0; 1024 * 1024]).await.unwrap();
server.shutdown().await.unwrap();
})
.await
.unwrap();
drop(server);
assert_eq!(b.gateway.entries.count(), 0);
tokio::time::timeout(Duration::from_secs(15), async {
while a.gateway.entries.count() != 0
|| b.gateway.entries.count() != 0
|| a.gateway.net.lock().await.is_some()
|| b.gateway.net.lock().await.is_some()
{
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("TCP teardown must release the last flow and stack leases");
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 +611,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 +928,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 +959,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 +992,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;
}
@@ -11,11 +11,12 @@ use tokio::{
sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel},
task::JoinSet,
};
use tokio_util::sync::CancellationToken;
use crate::{
packet::ZCPacket,
peers::{
PeerPacketFilter,
PeerPacketFilter, PeerPacketFilterResult,
peer_manager::{PeerManagerCore, PipelineRegistrationGuard},
},
};
@@ -26,23 +27,6 @@ use super::{
traits::{IcmpProxyRuntime, IcmpProxySocket, ProxyRuntimeError},
};
async fn start_icmp_runtime<R: IcmpProxyRuntime + 'static>(
runtime: &R,
no_tun: bool,
) -> Result<Option<Arc<R::Socket>>, ProxyRuntimeError> {
match runtime.start_icmp().await {
Ok(socket) => Ok(Some(socket)),
Err(err) => {
runtime.stop_icmp();
if !no_tun {
return Err(err);
}
tracing::warn!(?err, "start ICMP runtime failed without TUN");
Ok(None)
}
}
}
pub struct IcmpProxyService<R: IcmpProxyRuntime + 'static> {
peer_manager: Arc<PeerManagerCore>,
runtime: Arc<R>,
@@ -52,6 +36,8 @@ pub struct IcmpProxyService<R: IcmpProxyRuntime + 'static> {
pipeline_guard: std::sync::Mutex<Option<PipelineRegistrationGuard>>,
tasks: std::sync::Mutex<JoinSet<()>>,
socket: std::sync::Mutex<Option<Arc<R::Socket>>>,
socket_init: tokio::sync::Mutex<()>,
stopped: CancellationToken,
runtime_started: AtomicBool,
started: AtomicBool,
}
@@ -73,6 +59,8 @@ impl<R: IcmpProxyRuntime + 'static> IcmpProxyService<R> {
pipeline_guard: std::sync::Mutex::new(None),
tasks: std::sync::Mutex::new(JoinSet::new()),
socket: std::sync::Mutex::new(None),
socket_init: tokio::sync::Mutex::new(()),
stopped: CancellationToken::new(),
runtime_started: AtomicBool::new(false),
started: AtomicBool::new(false),
})
@@ -84,17 +72,6 @@ impl<R: IcmpProxyRuntime + 'static> IcmpProxyService<R> {
}
let snapshot = self.runtime.proxy_runtime_snapshot();
let socket = match start_icmp_runtime(self.runtime.as_ref(), snapshot.no_tun).await {
Ok(socket) => socket,
Err(err) => {
self.started.store(false, Ordering::Release);
return Err(err);
}
};
let runtime_started = socket.is_some();
self.runtime_started
.store(runtime_started, Ordering::Release);
self.socket.lock().unwrap().clone_from(&socket);
if let Some(mut response_rx) = self.response_rx.lock().unwrap().take() {
let peer_manager = self.peer_manager.clone();
@@ -113,33 +90,6 @@ impl<R: IcmpProxyRuntime + 'static> IcmpProxyService<R> {
});
}
if let Some(socket) = socket {
let service = Arc::downgrade(self);
self.tasks.lock().unwrap().spawn(async move {
loop {
let recv_result = socket.recv().await;
let (peer_ip, mut packet) = match recv_result {
Ok(packet) => packet,
Err(err) => {
tracing::error!(?err, "receive ICMP packet failed");
continue;
}
};
if packet.is_empty() {
tracing::error!("received empty ICMP packet");
break;
}
let IpAddr::V4(peer_ip) = peer_ip else {
continue;
};
let Some(service) = service.upgrade() else {
break;
};
service.handle_socket_response(peer_ip, &mut packet);
}
});
}
let service = Arc::downgrade(self);
self.tasks.lock().unwrap().spawn(async move {
loop {
@@ -176,9 +126,13 @@ impl<R: IcmpProxyRuntime + 'static> IcmpProxyService<R> {
}
pub fn stop(&self) {
// Serialize shutdown with publication of a newly opened socket and its
// receive task. Opening itself must not hold this synchronous lock.
let mut socket = self.socket.lock().unwrap();
if !self.started.swap(false, Ordering::AcqRel) {
return;
}
self.stopped.cancel();
if let Some(guard) = self.pipeline_guard.lock().unwrap().take() {
guard.close();
}
@@ -186,7 +140,61 @@ impl<R: IcmpProxyRuntime + 'static> IcmpProxyService<R> {
if self.runtime_started.swap(false, Ordering::AcqRel) {
self.runtime.stop_icmp();
}
self.socket.lock().unwrap().take();
socket.take();
}
async fn socket(self: &Arc<Self>) -> Result<Arc<R::Socket>, ProxyRuntimeError> {
let _init = self.socket_init.lock().await;
if !self.started.load(Ordering::Acquire) || self.stopped.is_cancelled() {
return Err(std::io::Error::from(std::io::ErrorKind::NotConnected).into());
}
if let Some(socket) = self.socket.lock().unwrap().clone() {
return Ok(socket);
}
// Roll back partial resources on errors, caller cancellation or stop.
let rollback = guarden::guard! { self.runtime.stop_icmp(); };
let socket = tokio::select! {
biased;
_ = self.stopped.cancelled() => {
return Err(std::io::Error::from(std::io::ErrorKind::NotConnected).into());
}
socket = self.runtime.start_icmp() => socket?,
};
let mut current = self.socket.lock().unwrap();
if !self.started.load(Ordering::Acquire) {
return Err(std::io::Error::from(std::io::ErrorKind::NotConnected).into());
}
let service = Arc::downgrade(self);
let receiver = socket.clone();
self.tasks.lock().unwrap().spawn(async move {
loop {
let recv_result = receiver.recv().await;
let (peer_ip, mut packet) = match recv_result {
Ok(packet) => packet,
Err(err) => {
tracing::error!(?err, "receive ICMP packet failed");
continue;
}
};
if packet.is_empty() {
tracing::error!("received empty ICMP packet");
break;
}
let IpAddr::V4(peer_ip) = peer_ip else {
continue;
};
let Some(service) = service.upgrade() else {
break;
};
service.handle_socket_response(peer_ip, &mut packet);
}
});
*current = Some(socket.clone());
self.runtime_started.store(true, Ordering::Release);
rollback.defuse();
Ok(socket)
}
async fn handle_peer_packet(self: Arc<Self>, packet: ZCPacket) -> Option<ZCPacket> {
@@ -204,14 +212,13 @@ impl<R: IcmpProxyRuntime + 'static> IcmpProxyService<R> {
destination,
packet: request,
} => {
let socket = self.socket.lock().unwrap().clone();
match socket {
Some(socket) => {
match self.socket().await {
Ok(socket) => {
if let Err(err) = socket.send(destination, &request).await {
tracing::error!(?err, "send ICMP packet through runtime failed");
}
}
None => tracing::error!("send ICMP packet without a runtime socket"),
Err(err) => tracing::error!(?err, "start ICMP runtime failed"),
}
None
}
@@ -249,35 +256,69 @@ 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()
}
}
#[cfg(test)]
mod tests {
use std::{
net::{IpAddr, Ipv4Addr},
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
},
};
use std::sync::atomic::AtomicUsize;
use smoltcp::wire::{Icmpv4Message, Icmpv4Packet, IpProtocol, Ipv4Packet};
use tokio::sync::Semaphore;
use super::*;
use crate::gateway::proxy::traits::{IcmpProxySocket, ProxyRuntimeInfo, ProxyRuntimeSnapshot};
use crate::{
config::peers::PeerRuntimeSnapshot,
gateway::proxy::{
cidr_table::{ProxyCidrRule, ProxyCidrSnapshot},
traits::{ProxyRuntimeInfo, ProxyRuntimeSnapshot},
},
packet::PacketType,
peers::peer_manager::PortablePeerManagerConfig,
};
#[derive(Default)]
struct PartialStartRuntime {
stop_count: AtomicUsize,
struct TestSocket {
sent: std::sync::Mutex<Vec<Ipv4Addr>>,
receives: AtomicUsize,
}
impl ProxyRuntimeInfo for PartialStartRuntime {
#[async_trait::async_trait]
impl IcmpProxySocket for TestSocket {
async fn send(
&self,
destination: Ipv4Addr,
_packet: &[u8],
) -> Result<(), ProxyRuntimeError> {
self.sent.lock().unwrap().push(destination);
Ok(())
}
async fn recv(&self) -> Result<(IpAddr, Vec<u8>), ProxyRuntimeError> {
self.receives.fetch_add(1, Ordering::AcqRel);
std::future::pending().await
}
}
#[derive(Default)]
struct TestRuntime {
snapshot: ProxyRuntimeSnapshot,
socket: Arc<TestSocket>,
starts: AtomicUsize,
stops: AtomicUsize,
active: AtomicBool,
fail: AtomicBool,
start_gate: Option<Semaphore>,
}
impl ProxyRuntimeInfo for TestRuntime {
fn proxy_runtime_snapshot(&self) -> ProxyRuntimeSnapshot {
ProxyRuntimeSnapshot::default()
self.snapshot
}
fn is_ip_local_virtual_ip(&self, _ip: &IpAddr) -> bool {
@@ -285,53 +326,252 @@ mod tests {
}
}
struct NoopIcmpSocket;
#[async_trait::async_trait]
impl IcmpProxySocket for NoopIcmpSocket {
async fn send(
&self,
_destination: Ipv4Addr,
_packet: &[u8],
) -> Result<(), ProxyRuntimeError> {
Ok(())
}
async fn recv(&self) -> Result<(IpAddr, Vec<u8>), ProxyRuntimeError> {
Err(std::io::Error::other("unused test socket").into())
}
}
#[async_trait::async_trait]
impl IcmpProxyRuntime for PartialStartRuntime {
type Socket = NoopIcmpSocket;
impl IcmpProxyRuntime for TestRuntime {
type Socket = TestSocket;
async fn start_icmp(&self) -> Result<Arc<Self::Socket>, ProxyRuntimeError> {
Err(std::io::Error::other("partial start").into())
self.starts.fetch_add(1, Ordering::AcqRel);
self.active.store(true, Ordering::Release);
if let Some(gate) = &self.start_gate {
gate.acquire().await.unwrap().forget();
}
tokio::task::yield_now().await;
if self.fail.load(Ordering::Acquire) {
return Err(std::io::Error::other("partial start").into());
}
Ok(self.socket.clone())
}
fn stop_icmp(&self) {
self.stop_count.fetch_add(1, Ordering::AcqRel);
self.stops.fetch_add(1, Ordering::AcqRel);
self.active.store(false, Ordering::Release);
}
}
#[tokio::test]
async fn failed_runtime_start_rolls_back_partial_resources() {
let runtime = PartialStartRuntime::default();
fn service(
runtime: Arc<TestRuntime>,
table: Arc<ProxyCidrTable>,
) -> Arc<IcmpProxyService<TestRuntime>> {
let (packet_tx, _packet_rx) = crate::host::packet::host_packet_channel();
let peer_manager = Arc::new(
PeerManagerCore::new_portable_for_test(
PortablePeerManagerConfig::new(PeerRuntimeSnapshot::default().runtime),
packet_tx,
)
.unwrap(),
);
IcmpProxyService::new(peer_manager, runtime, table, Duration::from_secs(10))
}
assert!(start_icmp_runtime(&runtime, false).await.is_err());
assert_eq!(runtime.stop_count.load(Ordering::Acquire), 1);
fn runtime(no_tun: bool, enable_exit_node: bool) -> Arc<TestRuntime> {
Arc::new(TestRuntime {
snapshot: ProxyRuntimeSnapshot {
virtual_ipv4: Some("10.0.0.1".parse().unwrap()),
no_tun,
enable_exit_node,
..Default::default()
},
..Default::default()
})
}
fn echo_request(destination: &str) -> ZCPacket {
let mut bytes = [0_u8; 28];
let mut request = Icmpv4Packet::new_unchecked(&mut bytes[20..]);
request.set_msg_type(Icmpv4Message::EchoRequest);
request.set_echo_ident(7);
request.set_echo_seq_no(11);
request.fill_checksum();
let mut ipv4 = Ipv4Packet::new_unchecked(&mut bytes[..]);
ipv4.set_version(4);
ipv4.set_header_len(20);
ipv4.set_total_len(28);
ipv4.set_hop_limit(64);
ipv4.set_next_header(IpProtocol::Icmp);
ipv4.set_src_addr("10.0.0.2".parse().unwrap());
ipv4.set_dst_addr(destination.parse().unwrap());
ipv4.fill_checksum();
let mut packet = ZCPacket::new_with_payload(&bytes);
packet.fill_peer_manager_hdr(101, 202, PacketType::Data as u8);
packet
}
#[tokio::test]
async fn no_tun_suppresses_start_error_after_rollback() {
let runtime = PartialStartRuntime::default();
async fn no_tun_local_echo_does_not_open_socket() {
let runtime = runtime(true, false);
let service = service(runtime.clone(), Arc::new(ProxyCidrTable::new()));
let mut responses = service.response_rx.lock().unwrap().take().unwrap();
service.start().await.unwrap();
assert!(
service
.clone()
.handle_peer_packet(echo_request("10.0.0.1"))
.await
.is_none()
);
let response = responses.try_recv().unwrap();
let ipv4 = Ipv4Packet::new_checked(response.payload()).unwrap();
let icmp = Icmpv4Packet::new_checked(ipv4.payload()).unwrap();
assert_eq!(icmp.msg_type(), Icmpv4Message::EchoReply);
assert_eq!(runtime.starts.load(Ordering::Acquire), 0);
assert_eq!(runtime.socket.receives.load(Ordering::Acquire), 0);
service.stop();
assert_eq!(runtime.stops.load(Ordering::Acquire), 0);
}
let socket = start_icmp_runtime(&runtime, true).await.unwrap();
assert!(socket.is_none());
if socket.is_some() {
runtime.stop_icmp();
}
assert_eq!(runtime.stop_count.load(Ordering::Acquire), 1);
#[tokio::test]
async fn dynamic_proxy_networks_open_once_and_follow_updated_rules() {
let runtime = runtime(false, false);
let table = Arc::new(ProxyCidrTable::new());
let service = service(runtime.clone(), table.clone());
service.start().await.unwrap();
assert!(
service
.clone()
.handle_peer_packet(echo_request("192.0.2.42"))
.await
.is_some()
);
assert_eq!(runtime.starts.load(Ordering::Acquire), 0);
table.update_snapshot(ProxyCidrSnapshot {
rules: vec![ProxyCidrRule {
cidr: "127.0.0.0/24".parse().unwrap(),
mapped_cidr: Some("192.0.2.0/24".parse().unwrap()),
}],
});
// The first concurrent requests must share both the socket and receiver.
futures::future::join_all((0..16).map(|_| {
service
.clone()
.handle_peer_packet(echo_request("192.0.2.42"))
}))
.await;
tokio::task::yield_now().await;
assert_eq!(runtime.starts.load(Ordering::Acquire), 1);
assert_eq!(runtime.socket.receives.load(Ordering::Acquire), 1);
assert_eq!(
*runtime.socket.sent.lock().unwrap(),
vec!["127.0.0.42".parse::<Ipv4Addr>().unwrap(); 16]
);
table.update_snapshot(ProxyCidrSnapshot::default());
assert!(
service
.clone()
.handle_peer_packet(echo_request("192.0.2.42"))
.await
.is_some()
);
assert_eq!(runtime.socket.sent.lock().unwrap().len(), 16);
// Keep the socket for in-flight replies and subsequent network additions.
assert_eq!(runtime.stops.load(Ordering::Acquire), 0);
table.update_snapshot(ProxyCidrSnapshot {
rules: vec![ProxyCidrRule {
cidr: "192.0.2.0/24".parse().unwrap(),
mapped_cidr: None,
}],
});
assert!(
service
.clone()
.handle_peer_packet(echo_request("192.0.2.42"))
.await
.is_none()
);
assert_eq!(runtime.starts.load(Ordering::Acquire), 1);
assert_eq!(
runtime.socket.sent.lock().unwrap().last(),
Some(&"192.0.2.42".parse().unwrap())
);
service.stop();
service.stop();
assert_eq!(runtime.stops.load(Ordering::Acquire), 1);
assert!(!runtime.active.load(Ordering::Acquire));
assert!(service.socket().await.is_err());
}
#[tokio::test]
async fn exit_node_opens_socket_without_proxy_networks() {
let runtime = runtime(false, true);
let service = service(runtime.clone(), Arc::new(ProxyCidrTable::new()));
service.start().await.unwrap();
assert_eq!(runtime.starts.load(Ordering::Acquire), 0);
let mut packet = echo_request("192.0.2.42");
packet
.mut_peer_manager_header()
.unwrap()
.set_exit_node(true);
assert!(service.clone().handle_peer_packet(packet).await.is_none());
assert_eq!(runtime.starts.load(Ordering::Acquire), 1);
assert_eq!(
*runtime.socket.sent.lock().unwrap(),
["192.0.2.42".parse::<Ipv4Addr>().unwrap()]
);
}
#[tokio::test]
async fn failed_runtime_start_rolls_back_and_can_retry() {
let runtime = runtime(true, false);
runtime.fail.store(true, Ordering::Release);
let service = service(runtime.clone(), Arc::new(ProxyCidrTable::new()));
let mut responses = service.response_rx.lock().unwrap().take().unwrap();
service.start().await.unwrap();
assert!(service.socket().await.is_err());
assert_eq!(runtime.stops.load(Ordering::Acquire), 1);
assert!(!runtime.active.load(Ordering::Acquire));
assert!(
service
.clone()
.handle_peer_packet(echo_request("10.0.0.1"))
.await
.is_none()
);
assert!(responses.try_recv().is_ok());
assert_eq!(runtime.starts.load(Ordering::Acquire), 1);
runtime.fail.store(false, Ordering::Release);
assert!(service.socket().await.is_ok());
service.stop();
assert_eq!(runtime.stops.load(Ordering::Acquire), 2);
}
#[tokio::test]
async fn stop_cancels_pending_socket_start() {
let runtime = Arc::new(TestRuntime {
start_gate: Some(Semaphore::new(0)),
..Default::default()
});
let service = service(runtime.clone(), Arc::new(ProxyCidrTable::new()));
service.start().await.unwrap();
let mut opening = Box::pin(service.socket());
assert!(futures::poll!(&mut opening).is_pending());
assert!(runtime.active.load(Ordering::Acquire));
service.stop();
assert!(opening.await.is_err());
assert!(!runtime.active.load(Ordering::Acquire));
assert_eq!(runtime.stops.load(Ordering::Acquire), 1);
assert_eq!(runtime.socket.receives.load(Ordering::Acquire), 0);
assert!(service.socket().await.is_err());
assert_eq!(runtime.starts.load(Ordering::Acquire), 1);
}
#[tokio::test]
async fn cancelled_caller_rolls_back_socket_start() {
let runtime = Arc::new(TestRuntime {
start_gate: Some(Semaphore::new(0)),
..Default::default()
});
let service = service(runtime.clone(), Arc::new(ProxyCidrTable::new()));
service.start().await.unwrap();
let mut opening = Box::pin(service.socket());
assert!(futures::poll!(&mut opening).is_pending());
drop(opening);
assert!(!runtime.active.load(Ordering::Acquire));
assert_eq!(runtime.stops.load(Ordering::Acquire), 1);
runtime.start_gate.as_ref().unwrap().add_permits(1);
assert!(service.socket().await.is_ok());
service.stop();
assert_eq!(runtime.stops.load(Ordering::Acquire), 2);
}
}
+5 -5
View File
@@ -379,12 +379,12 @@ where
return Ok(());
}
// Runtime updates can be cancelled while a host operation is pending.
// Roll back the startup flags and resources so a later update can retry.
let rollback = guarden::guard! { self.stop_started(); };
self.runtime.latch_smoltcp();
self.tcp_started.store(true, Ordering::Release);
if let Err(error) = self.tcp.start(true).await {
self.stop_started();
return Err(error);
}
self.tcp.start(true).await?;
if let Some(icmp) = &self.icmp {
self.icmp_started.store(true, Ordering::Release);
@@ -398,7 +398,6 @@ where
.proxy
.icmp_failure_is_fatal
{
self.stop_started();
return Err(error);
}
tracing::warn!(?error, "optional ICMP proxy runtime failed to start");
@@ -407,6 +406,7 @@ where
self.udp_started.store(true, Ordering::Release);
self.udp.start().await;
rollback.defuse();
Ok(())
}
@@ -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]
+1 -1
View File
@@ -3,5 +3,5 @@ mod tokio_smoltcp;
pub(super) use stack::{SmolTcpStack, output_dst_ip};
pub(super) use tokio_smoltcp::{
BufferSize, Net, NetConfig, TcpListener, UdpSocket, channel_device,
BufferSize, Net, NetConfig, TcpListener, TcpStream, UdpSocket, channel_device,
};
@@ -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);
}
}
@@ -24,7 +24,10 @@ use std::{
sync::Arc,
task::{Context as TaskContext, Poll, Waker},
};
use tokio::{pin, select, sync::Notify};
use tokio::{
pin, select,
sync::{Notify, mpsc},
};
use crate::foundation::time::sleep;
@@ -50,11 +53,18 @@ struct TcpListenerRegistry {
type SharedTcpListenerRegistry = Arc<Mutex<TcpListenerRegistry>>;
struct ClosingTcpSocket {
handle: OwnedSocketHandle,
deadline: Instant,
_keep_alive: Box<dyn Send>,
}
pub(crate) struct Reactor {
notify: Arc<Notify>,
iface: BufferInterface,
socket_allocator: SocketAlloctor,
tcp_listeners: SharedTcpListenerRegistry,
closing_tcp: mpsc::UnboundedSender<ClosingTcpSocket>,
}
async fn receive(
@@ -203,12 +213,14 @@ fn update_tcp_listeners(
wakers
}
#[allow(clippy::too_many_arguments)]
async fn run(
mut async_iface: impl super::device::AsyncDevice,
iface: BufferInterface,
mut device: BufferDevice,
socket_allocator: SocketAlloctor,
tcp_listeners: SharedTcpListenerRegistry,
mut closing_tcp: mpsc::UnboundedReceiver<ClosingTcpSocket>,
notify: Arc<Notify>,
stopper: Arc<Notify>,
) -> io::Result<()> {
@@ -219,21 +231,36 @@ async fn run(
.max_burst_size
.unwrap_or(MAX_BURST_SIZE);
let mut recv_buf = VecDeque::with_capacity(max_burst_size);
let mut closing = Vec::<ClosingTcpSocket>::new();
let mut closed = Vec::new();
pin!(timer);
loop {
let packets = device.take_send_queue();
async_iface.send_all(&mut iter(packets).map(Ok)).await?;
// Keep routing and stack leases until the last protocol output has
// left the device queue. Drop handles outside the socket-set lock.
closed.clear();
while let Ok(socket) = closing_tcp.try_recv() {
closing.push(socket);
}
if recv_buf.is_empty() && device.need_wait() {
let start = Instant::now();
let deadline = {
let mut deadline = {
iface
.lock()
.poll_delay(start, &socket_allocator.sockets().lock())
.unwrap_or(default_timeout)
};
for socket in &closing {
deadline = deadline.min(if socket.deadline <= start {
Duration::ZERO
} else {
socket.deadline - start
});
}
if deadline != Duration::ZERO {
timer
@@ -243,6 +270,7 @@ async fn run(
_ = &mut timer => {},
_ = receive(&mut async_iface,&mut recv_buf) => {}
_ = notify.notified() => {}
Some(socket) = closing_tcp.recv() => closing.push(socket),
_ = stopper.notified() => break,
};
}
@@ -290,6 +318,21 @@ async fn run(
iface.poll(now, &mut device, &mut sockets);
listener_wakers.extend(update_tcp_listeners(&mut listeners, &mut sockets));
if prepare_tcp_close(now, &closing, &mut sockets) {
iface.poll(now, &mut device, &mut sockets);
}
let mut index = 0;
while index < closing.len() {
let socket = sockets.get::<tcp::Socket>(*closing[index].handle);
// Closed can still have an RST to dispatch. TIME-WAIT must also
// remain available to acknowledge a retransmitted peer FIN.
if socket.state() == tcp::State::Closed && socket.remote_endpoint().is_none() {
closed.push(closing.swap_remove(index));
} else {
index += 1;
}
}
// wake up all closed sockets (smoltcp seems have a bug that it doesn't wake up closed sockets)
for (_, socket) in sockets.iter_mut() {
if let Socket::Tcp(tcp) = socket
@@ -309,6 +352,30 @@ async fn run(
Ok(())
}
fn prepare_tcp_close(
now: Instant,
closing: &[ClosingTcpSocket],
sockets: &mut SocketSet<'static>,
) -> bool {
let mut changed = false;
for entry in closing {
let socket = sockets.get_mut::<tcp::Socket>(*entry.handle);
// There is no application reader anymore. Keep the receive window
// open so queued peer data cannot prevent its FIN from reaching us.
if socket.can_recv() {
let _ = socket.recv(|data| (data.len(), ()));
changed = true;
}
// The normal TCP timeout measures inactivity, so ACKs can extend it.
// A released application resource has an absolute teardown deadline.
if now >= entry.deadline && socket.state() != tcp::State::Closed {
socket.abort();
changed = true;
}
}
changed
}
impl Reactor {
pub fn new(
async_device: impl super::device::AsyncDevice,
@@ -321,12 +388,14 @@ impl Reactor {
let notify = Arc::new(Notify::new());
let socket_allocator = SocketAlloctor::new(buffer_size);
let tcp_listeners = Arc::new(Mutex::new(TcpListenerRegistry::default()));
let (closing_tx, closing_rx) = mpsc::unbounded_channel();
let fut = run(
async_device,
iface.clone(),
device,
socket_allocator.clone(),
tcp_listeners.clone(),
closing_rx,
notify.clone(),
stopper,
);
@@ -337,6 +406,7 @@ impl Reactor {
iface,
socket_allocator,
tcp_listeners,
closing_tcp: closing_tx,
},
fut,
)
@@ -350,6 +420,21 @@ impl Reactor {
|sockets: &mut smoltcp::iface::SocketSet<'_>| sockets.get_mut::<T>(handle),
)
}
pub(super) fn close_tcp(
&self,
handle: OwnedSocketHandle,
keep_alive: Box<dyn Send>,
deadline: Instant,
) {
self.get_socket::<tcp::Socket>(*handle).close();
// The receiver and all closing sockets belong to run(), so stopping
// the reactor also releases them without a detached cleanup task.
let _ = self.closing_tcp.send(ClosingTcpSocket {
handle,
deadline,
_keep_alive: keep_alive,
});
}
pub fn context(&self) -> MappedMutexGuard<'_, Context> {
MutexGuard::map(self.iface.lock(), |iface| iface.context())
}
@@ -97,6 +97,15 @@ pub struct TcpStream {
}
impl TcpStream {
/// Release the application stream while the reactor finishes TCP teardown.
pub(crate) fn close(self, keep_alive: impl Send + 'static) {
self.reactor.close_tcp(
self.handle,
Box::new(keep_alive),
smoltcp::time::Instant::now() + super::socket_allocator::TCP_TIMEOUT,
);
}
pub(super) async fn connect(
reactor: Arc<Reactor>,
local_endpoint: IpEndpoint,
@@ -331,21 +340,21 @@ impl UdpSocket {
#[cfg(test)]
mod tests {
use std::time::Duration as StdDuration;
use std::{sync::Arc, time::Duration as StdDuration};
use smoltcp::{
iface::Config,
phy::{DeviceCapabilities, Medium},
socket::tcp,
time::Duration,
wire::{HardwareAddress, Ipv4Address, TcpControl, TcpSeqNumber},
wire::{HardwareAddress, Ipv4Address, Ipv4Packet, TcpControl, TcpPacket, TcpSeqNumber},
};
use super::super::{
Net, NetConfig, channel_device,
test_utils::{TcpPackets, recv_tcp, recv_tcp_for_port},
};
use super::{TCP_LISTENER_MAX_PENDING, TcpListener};
use super::{TCP_LISTENER_MAX_PENDING, TcpListener, TcpStream};
const LISTEN_ADDR: Ipv4Address = Ipv4Address::new(10, 126, 126, 1);
const LISTEN_PORT: u16 = 34569;
@@ -417,6 +426,136 @@ mod tests {
.collect()
}
async fn connected_stream(
net: &Net,
ingress: &tokio::sync::mpsc::Sender<std::io::Result<Vec<u8>>>,
egress: &mut tokio::sync::mpsc::Receiver<Vec<u8>>,
) -> TcpStream {
let mut listener = net
.tcp_bind("10.126.126.1:34569".parse().unwrap())
.await
.unwrap();
let client_addr = Ipv4Address::new(10, 126, 126, 2);
let sequence = begin_handshake(ingress, egress, client_addr, 40000, 1000).await;
finish_handshake(ingress, client_addr, 40000, 1000, sequence).await;
tokio::time::timeout(StdDuration::from_secs(1), listener.accept())
.await
.unwrap()
.unwrap()
.0
}
#[tokio::test]
async fn closing_stream_retransmits_fin_and_retains_time_wait() {
let (net, ingress, mut egress) = test_net();
let stream = connected_stream(&net, &ingress, &mut egress).await;
let reactor = stream.reactor.clone();
let keep_alive = Arc::new(());
let retained = Arc::downgrade(&keep_alive);
stream.close(keep_alive);
let fin = recv_tcp(&mut egress).await;
assert_eq!(fin.control, TcpControl::Fin);
// Lose the first FIN. The application handle is gone, but the reactor
// must still retransmit the same sequence number.
let retransmit = tokio::time::timeout(StdDuration::from_secs(3), egress.recv())
.await
.unwrap()
.unwrap();
let ipv4 = Ipv4Packet::new_checked(&retransmit).unwrap();
let tcp = TcpPacket::new_checked(ipv4.payload()).unwrap();
assert!(tcp.fin());
assert_eq!(tcp.seq_number(), fin.sequence);
let client_addr = Ipv4Address::new(10, 126, 126, 2);
let peer_fin = PACKETS.fin(client_addr, 40000, TcpSeqNumber(1001), fin.sequence + 1);
ingress.send(Ok(peer_fin.clone())).await.unwrap();
let ack = recv_tcp(&mut egress).await;
assert_eq!(ack.control, TcpControl::None);
assert_eq!(ack.ack_number, Some(TcpSeqNumber(1002)));
assert!(retained.upgrade().is_some());
// Lose our final ACK. TIME-WAIT must acknowledge the repeated FIN.
ingress.send(Ok(peer_fin)).await.unwrap();
let ack = recv_tcp(&mut egress).await;
assert_eq!(ack.control, TcpControl::None);
assert_eq!(ack.ack_number, Some(TcpSeqNumber(1002)));
tokio::time::timeout(StdDuration::from_secs(15), async {
while retained.upgrade().is_some() {
tokio::time::sleep(StdDuration::from_millis(10)).await;
}
})
.await
.expect("TIME-WAIT must eventually release the socket and its leases");
assert_eq!(
reactor.socket_allocator().sockets().lock().iter().count(),
0
);
}
#[tokio::test]
async fn closing_stream_deadline_sends_reset_and_releases_socket() {
let (net, ingress, mut egress) = test_net();
let stream = connected_stream(&net, &ingress, &mut egress).await;
let reactor = stream.reactor.clone();
let keep_alive = Arc::new(());
let retained = Arc::downgrade(&keep_alive);
// Exercise the same reactor deadline with a short test budget.
reactor.close_tcp(
stream.handle,
Box::new(keep_alive),
smoltcp::time::Instant::now() + Duration::from_millis(150),
);
let fin = recv_tcp(&mut egress).await;
assert_eq!(fin.control, TcpControl::Fin);
ingress
.send(Ok(PACKETS.ack(
Ipv4Address::new(10, 126, 126, 2),
40000,
TcpSeqNumber(1001),
fin.sequence + 1,
)))
.await
.unwrap();
// The peer ACKs our FIN but never closes its own half (FIN-WAIT-2).
let reset = recv_tcp(&mut egress).await;
assert_eq!(reset.control, TcpControl::Rst);
tokio::time::timeout(StdDuration::from_secs(1), async {
while retained.upgrade().is_some() {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
assert_eq!(
reactor.socket_allocator().sockets().lock().iter().count(),
0
);
}
#[tokio::test]
async fn stopping_reactor_releases_closing_streams() {
let (net, ingress, mut egress) = test_net();
let stream = connected_stream(&net, &ingress, &mut egress).await;
let reactor = stream.reactor.clone();
let keep_alive = Arc::new(());
let retained = Arc::downgrade(&keep_alive);
stream.close(keep_alive);
assert_eq!(recv_tcp(&mut egress).await.control, TcpControl::Fin);
drop(net);
tokio::time::timeout(StdDuration::from_secs(1), async {
while retained.upgrade().is_some() {
tokio::task::yield_now().await;
}
})
.await
.expect("stopping the reactor must release unfinished TCP teardown");
assert_eq!(
reactor.socket_allocator().sockets().lock().iter().count(),
0
);
}
#[tokio::test]
async fn duplicate_syn_does_not_consume_another_backlog_slot() {
let (net, ingress, mut egress) = test_net();
@@ -114,6 +114,7 @@ pub(crate) struct TcpPacketSummary {
pub(crate) control: TcpControl,
pub(crate) sequence: TcpSeqNumber,
pub(crate) dst_port: u16,
pub(crate) ack_number: Option<TcpSeqNumber>,
}
pub(crate) async fn recv_tcp(receiver: &mut mpsc::Receiver<Vec<u8>>) -> TcpPacketSummary {
@@ -133,6 +134,7 @@ pub(crate) async fn recv_tcp(receiver: &mut mpsc::Receiver<Vec<u8>>) -> TcpPacke
control: repr.control,
sequence: repr.seq_number,
dst_port: repr.dst_port,
ack_number: repr.ack_number,
}
}
+64 -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,44 @@ 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 tcp_bind_gate: Option<Arc<tokio::sync::Semaphore>>,
pub tcp_bind_runtime: Mutex<Option<tokio::runtime::Id>>,
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))
@@ -144,6 +181,10 @@ impl VirtualTcpListenerFactory for TestHost {
async fn bind_tcp(&self, options: TcpListenOptions) -> anyhow::Result<Arc<Self::Listener>> {
self.tcp_binds.fetch_add(1, Ordering::Relaxed);
*self.tcp_bind_runtime.lock().unwrap() = Some(tokio::runtime::Handle::current().id());
if let Some(gate) = &self.tcp_bind_gate {
gate.acquire().await.unwrap().forget();
}
if self.reject_socks5_listener && options.purpose == TcpListenPurpose::Socks5 {
anyhow::bail!("test host rejected SOCKS5 listener");
}
@@ -159,10 +200,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(),
+66 -8
View File
@@ -92,6 +92,16 @@ impl Default for CoreInstanceHostConfig {
}
impl CoreInstanceHostConfig {
/// Record whether this host can serve the TCP subnet proxy from a user-space TCP stack.
///
/// Hosts without an OS TUN device (WASI guests, sandboxes) can only forward proxied TCP
/// through such a stack, so they must advertise it for the proxy runtime to pick that path.
/// `requires_smoltcp` is sticky: once the host needs the stack it keeps needing it.
pub fn set_smoltcp_stack(&mut self, available: bool) {
self.smoltcp_available = available;
self.requires_smoltcp |= available;
}
pub(crate) fn accepts_runtime_url(&self, url: &url::Url) -> bool {
!self.ignore_unsupported_config
|| self
@@ -210,14 +220,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 +312,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 +426,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(
@@ -738,3 +775,24 @@ data_compress_algo = "Zstd"
);
}
}
#[cfg(test)]
mod host_capability_tests {
use super::CoreInstanceHostConfig;
#[test]
fn set_smoltcp_stack_marks_the_host_as_capable_and_dependent() {
let mut config = CoreInstanceHostConfig::default();
config.set_smoltcp_stack(true);
assert!(config.smoltcp_available);
assert!(config.requires_smoltcp);
}
#[test]
fn set_smoltcp_stack_does_not_claim_a_missing_stack() {
let mut config = CoreInstanceHostConfig::default();
config.set_smoltcp_stack(false);
assert!(!config.smoltcp_available);
assert!(!config.requires_smoltcp);
}
}
+60 -19
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,30 +66,39 @@ 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(())
}
#[cfg(feature = "proxy-packet")]
async fn start_packet_proxy(&self) -> anyhow::Result<()> {
pub(super) async fn start_packet_proxy(&self) -> anyhow::Result<()> {
let config = self.runtime_config.snapshot();
let has_proxy_networks = !config.peer.runtime.core.routes.proxy_networks.is_empty();
if config.services.proxy.should_start(has_proxy_networks) {
self.packet_proxy
.start()
if let Some(runtime) = self.runtime_handle() {
let proxy = self.packet_proxy.clone();
tokio_util::task::AbortOnDropHandle::new(
runtime.spawn(async move { proxy.start().await }),
)
.await
.map_err(anyhow::Error::new)?;
.map_err(|error| anyhow::anyhow!("packet proxy start task failed: {error}"))??;
} else {
self.packet_proxy
.start()
.await
.map_err(anyhow::Error::new)?;
}
}
Ok(())
}
@@ -125,9 +134,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 +182,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 +225,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 +286,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())
}
+68 -23
View File
@@ -10,7 +10,7 @@ mod management;
mod management_extension;
mod management_state;
pub mod manager;
mod packet_io;
pub(crate) mod packet_io;
mod packet_plane;
#[cfg(feature = "proxy-packet")]
mod packet_proxy_extension;
@@ -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>,
@@ -525,6 +531,7 @@ where
public_ipv6_host,
public_ipv6_events,
);
let running_listeners = Arc::new(RunningListenerRegistry::default());
let stun = (connectivity_mode == CoreConnectivityMode::Full)
.then(|| Self::prepare_stun(&adapters, &config.connectivity));
let (peer_stun, foreign_rpc_registrar): (
@@ -536,6 +543,7 @@ where
Arc::new(ForeignDirectConnectorRpcRegistrar::new(
adapters.host.clone(),
stun.clone(),
running_listeners.clone(),
)),
),
None => (Arc::new(()), Arc::new(())),
@@ -627,7 +635,6 @@ where
accepted_tunnel_handler.clone(),
)),
};
let running_listeners = Arc::new(RunningListenerRegistry::default());
let PreparedListenerPlan {
transports,
external,
@@ -783,6 +790,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 +885,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 +937,63 @@ 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;
}
#[cfg(feature = "proxy-packet")]
if self.state() == CoreInstanceState::Running && self.startup_plan.packet_proxy {
// A running instance may gain its first proxy network here. Start
// from the published configuration, just as during initial startup.
self.start_packet_proxy().await?;
}
Ok(())
}
+27
View File
@@ -81,10 +81,37 @@ where
.evict_idle_sessions(idle);
}
#[doc(hidden)]
pub async fn foreign_network_has_gateway_for_test(
&self,
network_name: &str,
peer_id: crate::config::PeerId,
) -> Option<bool> {
self.peer_manager
.foreign_network_has_gateway(network_name, peer_id)
.await
}
#[doc(hidden)]
pub fn evict_unused_peer_sessions_for_test(&self, idle: Duration) {
self.peer_manager
.get_peer_session_store()
.evict_unused_sessions_idle(idle);
}
#[doc(hidden)]
pub fn foreign_forward_metered_bytes_for_test(&self, network_name: &str) -> Option<u64> {
self.peer_manager
.foreign_forward_metered_bytes(network_name)
}
#[doc(hidden)]
pub fn network_name_for_test(&self) -> String {
self.peer_manager.network_name().to_owned()
}
#[doc(hidden)]
pub fn instance_recv_metered_bytes_for_test(&self) -> Option<u64> {
self.peer_manager.instance_recv_metered_bytes()
}
}
File diff suppressed because it is too large Load Diff
+3
View File
@@ -1,3 +1,6 @@
#[cfg(all(feature = "system-certificates", target_os = "android"))]
compile_error!("the system-certificates feature is not supported on Android");
pub mod config;
pub mod connectivity;
pub mod events;
+35 -34
View File
@@ -145,7 +145,6 @@ impl<Accepted> ListenerFactory<Accepted> {
#[derive(Debug, Clone)]
pub struct ListenerManagerOptions {
pub max_listen_retries: usize,
pub listen_retry_delay: Duration,
pub accept_retry_delay: Duration,
}
@@ -153,9 +152,8 @@ pub struct ListenerManagerOptions {
impl Default for ListenerManagerOptions {
fn default() -> Self {
Self {
max_listen_retries: 5,
listen_retry_delay: Duration::from_secs(1),
accept_retry_delay: Duration::from_secs(1),
listen_retry_delay: Duration::from_millis(100),
accept_retry_delay: Duration::from_millis(100),
}
}
}
@@ -342,10 +340,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>(
@@ -360,7 +359,7 @@ async fn run_listener<Accepted, H>(
Accepted: Send + 'static,
H: AcceptedSocketHandler<Accepted> + ?Sized + 'static,
{
let mut listen_error_count = 0;
let mut listen_error_count: usize = 0;
loop {
let registered_listener = match initial_listener.take() {
Some(listener) => listener,
@@ -372,18 +371,15 @@ async fn run_listener<Accepted, H>(
RegisteredListener::new(listener, events.clone(), registry.clone())
}
Err(error) => {
listen_error_count += 1;
let will_retry = listen_error_count <= options.max_listen_retries;
listen_error_count = listen_error_count.saturating_add(1);
events.emit(CoreEvent::ListenerAddFailed {
url: listener.local_url(),
error: format!("{error:?}"),
retry_count: listen_error_count,
will_retry,
will_retry: true,
});
tracing::error!(?error, ?listener, "listener listen error");
if !will_retry {
return;
}
drop(listener);
crate::foundation::time::sleep(options.listen_retry_delay).await;
continue;
}
@@ -391,7 +387,7 @@ async fn run_listener<Accepted, H>(
}
};
let mut listener = registered_listener.listener;
let _registration = registered_listener.registration;
let registration = registered_listener.registration;
loop {
let listener_url = listener.local_url();
@@ -403,7 +399,6 @@ async fn run_listener<Accepted, H>(
error: format!("{error:?}"),
});
tracing::error!(?error, ?listener, "listener accept error");
crate::foundation::time::sleep(options.accept_retry_delay).await;
break;
}
};
@@ -422,6 +417,9 @@ async fn run_listener<Accepted, H>(
}
});
}
drop(listener);
drop(registration);
crate::foundation::time::sleep(options.accept_retry_delay).await;
}
}
@@ -650,7 +648,6 @@ mod tests {
ListenerManagerOptions {
accept_retry_delay: Duration::from_millis(1),
listen_retry_delay: Duration::from_millis(1),
max_listen_retries: 0,
},
);
@@ -703,7 +700,6 @@ mod tests {
ListenerManagerOptions {
accept_retry_delay: Duration::from_millis(1),
listen_retry_delay: Duration::from_millis(1),
max_listen_retries: 2,
},
);
@@ -750,7 +746,6 @@ mod tests {
ListenerManagerOptions {
accept_retry_delay: Duration::from_millis(1),
listen_retry_delay: Duration::from_millis(1),
max_listen_retries: 0,
},
);
@@ -782,7 +777,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!(
@@ -813,7 +809,6 @@ mod tests {
ListenerManagerOptions {
accept_retry_delay: Duration::from_millis(1),
listen_retry_delay: Duration::from_millis(1),
max_listen_retries: 0,
},
);
@@ -934,7 +929,6 @@ mod tests {
ListenerManagerOptions {
accept_retry_delay: Duration::from_millis(1),
listen_retry_delay: Duration::from_millis(1),
max_listen_retries: 0,
},
);
let accepts = Arc::new(Mutex::new(VecDeque::from([Ok::<_, anyhow::Error>(
@@ -997,12 +991,13 @@ mod tests {
}
#[tokio::test]
async fn listen_retry_exhaustion_keeps_in_flight_handler_tasks_until_manager_drop() {
async fn listen_recovery_keeps_in_flight_handler_tasks_until_manager_drop() {
let (drop_tx, mut drop_rx) = tokio::sync::oneshot::channel();
let listen_results = Arc::new(Mutex::new(VecDeque::from([
Ok(()),
Err(anyhow::anyhow!("bind failed")),
])));
let listen_results = Arc::new(Mutex::new(
std::iter::once(Ok(()))
.chain((0..8).map(|_| Err(anyhow::anyhow!("bind failed"))))
.collect::<VecDeque<_>>(),
));
let accepts = Arc::new(Mutex::new(VecDeque::from([
Ok::<_, anyhow::Error>(DropSignal(Some(drop_tx))),
Err(anyhow::anyhow!("accept failed")),
@@ -1014,7 +1009,6 @@ mod tests {
ListenerManagerOptions {
accept_retry_delay: Duration::from_millis(1),
listen_retry_delay: Duration::from_millis(1),
max_listen_retries: 0,
},
);
let listener_results = listen_results.clone();
@@ -1022,7 +1016,7 @@ mod tests {
manager.add_listener(
move || {
Box::new(DropSignalListener {
url: "mock://retry-exhausted".parse().unwrap(),
url: "mock://retrying".parse().unwrap(),
listen_results: listener_results.clone(),
accepts: listener_accepts.clone(),
})
@@ -1033,15 +1027,22 @@ mod tests {
manager.run().await.unwrap();
crate::foundation::time::timeout(Duration::from_secs(1), async {
loop {
if events.events.lock().unwrap().iter().any(|event| {
matches!(
let recovered = {
let events = events.events.lock().unwrap();
assert!(events.iter().all(|event| !matches!(
event,
CoreEvent::ListenerAddFailed {
will_retry: false,
..
}
)
}) {
)));
events
.iter()
.filter(|event| matches!(event, CoreEvent::ListenerAdded { .. }))
.count()
== 2
};
if recovered {
break;
}
crate::foundation::time::sleep(Duration::from_millis(1)).await;
@@ -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]
+204
View File
@@ -0,0 +1,204 @@
//! Local IP flow metadata for unfragmented TCP and UDP packets.
use std::net::SocketAddr;
use super::{
ZCPacket,
ip::{parse_ip_packet, parse_transport_ports},
};
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct FlowKey {
pub src: SocketAddr,
pub dst: SocketAddr,
pub protocol: u8,
}
pub(crate) fn parse_flow_key(packet: &ZCPacket) -> Option<FlowKey> {
let parsed = parse_ip_packet(packet.payload())?;
// Skip every actual fragment, including the first one with ports.
if parsed.is_fragment || !parsed.has_valid_length {
return None;
}
let (src_port, dst_port) = parse_transport_ports(parsed.protocol, parsed.transport_payload)?;
Some(FlowKey {
src: SocketAddr::new(parsed.src_ip, src_port?),
dst: SocketAddr::new(parsed.dst_ip, dst_port?),
protocol: parsed.protocol,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::packet::{PacketType, ZCPacketType};
fn ipv4(protocol: u8, flags: u16, id: u16) -> ZCPacket {
let len = if protocol == 6 { 40 } else { 28 };
let mut bytes = vec![0; len];
bytes[0] = 0x45;
bytes[2..4].copy_from_slice(&(len as u16).to_be_bytes());
bytes[4..6].copy_from_slice(&id.to_be_bytes());
bytes[6..8].copy_from_slice(&flags.to_be_bytes());
bytes[9] = protocol;
bytes[12..16].copy_from_slice(&[10, 0, 0, 1]);
bytes[16..20].copy_from_slice(&[10, 0, 0, 2]);
bytes[20..24].copy_from_slice(&[0x12, 0x34, 0x56, 0x78]);
packet(bytes)
}
fn packet(bytes: Vec<u8>) -> ZCPacket {
let mut packet = ZCPacket::new_with_payload(&bytes);
packet.fill_peer_manager_hdr(1, 2, PacketType::Data as u8);
packet
}
fn ipv6(fragment: Option<u16>) -> ZCPacket {
let mut bytes = vec![0; if fragment.is_some() { 64 } else { 56 }];
bytes[0] = 0x60;
let len = (bytes.len() - 40) as u16;
bytes[4..6].copy_from_slice(&len.to_be_bytes());
bytes[6] = 0; // Hop-by-hop options before the transport/fragment header.
bytes[23] = 1;
bytes[39] = 2;
bytes[40] = if fragment.is_some() { 44 } else { 17 };
let offset = if let Some(flags) = fragment {
bytes[48] = 17;
bytes[50..52].copy_from_slice(&flags.to_be_bytes());
bytes[52..56].copy_from_slice(&99u32.to_be_bytes());
56
} else {
48
};
bytes[offset..offset + 4].copy_from_slice(&[0x12, 0x34, 0x56, 0x78]);
packet(bytes)
}
#[test]
fn identifies_ipv4_tcp_udp_and_ipv6_extension_headers() {
let udp = parse_flow_key(&ipv4(17, 0, 0)).unwrap();
assert_eq!(udp.src, "10.0.0.1:4660".parse().unwrap());
assert_eq!(udp.dst, "10.0.0.2:22136".parse().unwrap());
let tcp = parse_flow_key(&ipv4(6, 0, 0)).unwrap();
assert_eq!(tcp.src, udp.src);
assert_eq!(tcp.dst, udp.dst);
assert_eq!(tcp.protocol, 6);
assert_ne!(udp, tcp);
assert_eq!(Some(udp), parse_flow_key(&ipv4(17, 0x4000, 0)));
let v6 = parse_flow_key(&ipv6(None)).unwrap();
assert_eq!(v6.src, "[::1]:4660".parse().unwrap());
assert_eq!(Some(v6), parse_flow_key(&ipv6(Some(0))));
}
#[test]
fn first_middle_and_last_fragments_do_not_supply_a_flow_key() {
for protocol in [6, 17, 1, 50] {
for flags in [0x2000, 0x2001, 1] {
assert!(parse_flow_key(&ipv4(protocol, flags, 99)).is_none());
}
}
for protocol in [6, 17, 58, 50] {
for flags in [1, 9, 8] {
let mut fragment = ipv6(Some(flags));
fragment.mut_payload()[48] = protocol;
assert!(parse_flow_key(&fragment).is_none());
}
}
}
#[test]
fn flow_key_survives_local_conversions_but_is_not_on_the_wire() {
let mut original = ipv4(17, 0, 0);
original.flow_key = parse_flow_key(&original);
let key = original.flow_key;
assert!(key.is_some());
let mut converted = original.clone();
for kind in [
ZCPacketType::TCP,
ZCPacketType::UDP,
ZCPacketType::DummyTunnel,
ZCPacketType::TCP,
] {
converted = converted.convert_type(kind);
assert_eq!(converted.flow_key, key);
assert_eq!(converted.payload(), original.payload());
}
let received = ZCPacket::new_from_buf(converted.inner(), ZCPacketType::TCP);
assert!(received.flow_key.is_none());
let wrapped = ZCPacket::new_for_foreign_network(&"network".to_owned(), 2, &original);
assert_eq!(wrapped.flow_key, key);
let unwrapped = wrapped.foreign_network_packet();
assert_eq!(unwrapped.flow_key, key);
assert_eq!(unwrapped.payload(), original.payload());
}
#[test]
fn truncated_headers_never_supply_a_flow_key() {
for original in [ipv4(17, 0, 0), ipv6(None), ipv6(Some(0))] {
for len in 0..original.payload().len() {
assert!(parse_flow_key(&packet(original.payload()[..len].to_vec())).is_none());
}
}
}
#[test]
fn invalid_ip_header_lengths_do_not_supply_a_flow_key() {
for ihl in [0, 4, 15] {
let mut packet = ipv4(17, 0, 0);
packet.mut_payload()[0] = 0x40 | ihl;
assert!(parse_flow_key(&packet).is_none());
}
}
#[test]
fn incomplete_transport_headers_do_not_supply_a_flow_key() {
for (protocol, header_len) in [(6, 20), (17, 8)] {
for len in 0..header_len {
let mut v4 = ipv4(protocol, 0, 0);
let offset = v4.payload_offset();
v4.mut_inner().truncate(offset + 20 + len);
v4.mut_payload()[2..4].copy_from_slice(&((20 + len) as u16).to_be_bytes());
assert!(parse_flow_key(&v4).is_none());
let mut v6 = ipv6(None);
let offset = v6.payload_offset();
v6.mut_inner().resize(offset + 48 + len, 0);
v6.mut_payload()[4..6].copy_from_slice(&((8 + len) as u16).to_be_bytes());
v6.mut_payload()[40] = protocol;
assert!(parse_flow_key(&v6).is_none());
}
}
}
#[cfg(all(feature = "zstd", feature = "aes-gcm"))]
#[tokio::test]
async fn compression_and_encryption_preserve_local_flow_metadata() {
use crate::packet::{
CompressorAlgo,
compressor::{Compressor, DefaultCompressor},
};
let mut original = ipv4(17, 0, 0);
let offset = original.payload_offset();
original.mut_inner().resize(offset + 1024, 0);
original.mut_payload()[2..4].copy_from_slice(&1024u16.to_be_bytes());
original.fill_peer_manager_hdr(1, 2, PacketType::Data as u8);
let mut packet = original.clone();
packet.flow_key = parse_flow_key(&packet);
let key = packet.flow_key;
assert!(key.is_some());
let compressor = DefaultCompressor::new();
compressor
.compress(&mut packet, CompressorAlgo::ZstdDefault)
.await
.unwrap();
assert!(packet.peer_manager_header().unwrap().is_compressed());
let encryptor = crate::tunnel::encrypt::create_encryptor("aes-gcm", [1; 16], [2; 32]);
encryptor.encrypt(&mut packet).unwrap();
assert_eq!(packet.flow_key, key);
let mut packet = packet.convert_type(ZCPacketType::UDP);
encryptor.decrypt(&mut packet).unwrap();
compressor.decompress(&mut packet).await.unwrap();
assert_eq!(packet.flow_key, key);
assert_eq!(packet.payload(), original.payload());
}
}
+194
View File
@@ -0,0 +1,194 @@
//! Shared IP and transport-header parsing for packet consumers.
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
pub(crate) const IP_PROTO_ICMP: u8 = 1;
pub(crate) const IP_PROTO_TCP: u8 = 6;
pub(crate) const IP_PROTO_UDP: u8 = 17;
pub(crate) const IP_PROTO_ICMPV6: u8 = 58;
pub(crate) const IP_PROTO_UNSPECIFIED: u8 = u8::MAX;
pub(crate) const IPV6_HOP_BY_HOP: u8 = 0;
pub(crate) const IPV6_ROUTING: u8 = 43;
pub(crate) const IPV6_FRAGMENT: u8 = 44;
pub(crate) const IPV6_AUTHENTICATION: u8 = 51;
pub(crate) const IPV6_DESTINATION_OPTIONS: u8 = 60;
#[derive(Clone, Copy)]
pub(crate) struct ParsedIpPacket<'a> {
pub src_ip: IpAddr,
pub dst_ip: IpAddr,
pub protocol: u8,
pub transport_payload: &'a [u8],
pub is_non_initial_fragment: bool,
pub is_fragment: bool,
/// Whether the IP header and declared packet length fit in the input.
pub has_valid_length: bool,
}
pub(crate) fn parse_ip_packet(payload: &[u8]) -> Option<ParsedIpPacket<'_>> {
let version = payload.first()? >> 4;
match version {
4 => parse_ipv4_packet(payload),
6 => parse_ipv6_packet(payload),
_ => None,
}
}
fn parse_ipv4_packet(payload: &[u8]) -> Option<ParsedIpPacket<'_>> {
if payload.len() < 20 {
return None;
}
let header_len = usize::from(payload[0] & 0x0f) * 4;
let options_len = header_len.saturating_sub(20);
let payload_offset = 20 + options_len;
let payload_start = payload_offset.min(payload.len());
let total_length = usize::from(u16::from_be_bytes([payload[2], payload[3]]));
let payload_len = total_length.saturating_sub(header_len);
let payload_end = payload_start.saturating_add(payload_len).min(payload.len());
Some(ParsedIpPacket {
src_ip: IpAddr::V4(Ipv4Addr::new(
payload[12],
payload[13],
payload[14],
payload[15],
)),
dst_ip: IpAddr::V4(Ipv4Addr::new(
payload[16],
payload[17],
payload[18],
payload[19],
)),
protocol: payload[9],
transport_payload: payload.get(payload_start..payload_end)?,
is_non_initial_fragment: u16::from_be_bytes([payload[6], payload[7]]) & 0x1fff != 0,
is_fragment: u16::from_be_bytes([payload[6], payload[7]]) & 0x3fff != 0,
has_valid_length: header_len >= 20
&& total_length >= header_len
&& total_length <= payload.len(),
})
}
fn parse_ipv6_packet(payload: &[u8]) -> Option<ParsedIpPacket<'_>> {
if payload.len() < 40 {
return None;
}
let payload_len = usize::from(u16::from_be_bytes([payload[4], payload[5]]));
// A valid base header still supplies the addresses needed for ACL rules,
// even when the upper-layer headers cannot be decoded.
let parsed = ParsedIpPacket {
src_ip: IpAddr::V6(Ipv6Addr::from(<[u8; 16]>::try_from(&payload[8..24]).ok()?)),
dst_ip: IpAddr::V6(Ipv6Addr::from(<[u8; 16]>::try_from(&payload[24..40]).ok()?)),
protocol: IP_PROTO_UNSPECIFIED,
transport_payload: &[],
is_non_initial_fragment: false,
is_fragment: false,
has_valid_length: 40 + payload_len <= payload.len(),
};
let payload_end = 40usize.saturating_add(payload_len).min(payload.len());
let mut protocol = payload[6];
let mut payload_offset = 40;
let mut is_non_initial_fragment = false;
let mut is_fragment = false;
let mut saw_fragment_header = false;
loop {
match protocol {
IPV6_HOP_BY_HOP | IPV6_ROUTING | IPV6_DESTINATION_OPTIONS => {
let header = &payload[payload_offset..payload_end];
let Some(&header_len) = header.get(1) else {
return Some(parsed);
};
let extension_len = (usize::from(header_len) + 1) * 8;
let next_protocol = header[0];
let extension_end = payload_offset + extension_len;
if extension_end > payload_end {
return Some(parsed);
}
protocol = next_protocol;
payload_offset = extension_end;
}
IPV6_AUTHENTICATION => {
let header = &payload[payload_offset..payload_end];
let Some(&header_len) = header.get(1) else {
return Some(parsed);
};
let extension_len = (usize::from(header_len) + 2) * 4;
let next_protocol = header[0];
let extension_end = payload_offset + extension_len;
if extension_end > payload_end {
return Some(parsed);
}
protocol = next_protocol;
payload_offset = extension_end;
}
IPV6_FRAGMENT => {
if saw_fragment_header {
return Some(parsed);
}
saw_fragment_header = true;
let fragment_end = payload_offset + 8;
if fragment_end > payload_end {
return Some(parsed);
}
let header = &payload[payload_offset..fragment_end];
let fragment_field = u16::from_be_bytes([header[2], header[3]]);
is_non_initial_fragment = fragment_field & 0xfff8 != 0;
// An atomic fragment has its complete transport header.
is_fragment = fragment_field & 0xfff9 != 0;
let next_protocol = header[0];
payload_offset = fragment_end;
protocol = next_protocol;
if is_non_initial_fragment {
// Extension headers after the Fragment Header are only in the first
// fragment. Do not interpret tail data as an extension header.
// Only ICMPv6 tails can use the response records without a
// transport header. Leave other tails to address/group ACLs.
if protocol != IP_PROTO_ICMPV6 {
protocol = IP_PROTO_UNSPECIFIED;
}
break;
}
}
_ => break,
}
}
let transport_payload = &payload[payload_offset..payload_end];
if payload_offset != 40 && parse_transport_ports(protocol, transport_payload).is_none() {
return Some(parsed);
}
Some(ParsedIpPacket {
protocol,
transport_payload,
is_non_initial_fragment,
is_fragment,
..parsed
})
}
pub(crate) fn parse_transport_ports(
protocol: u8,
payload: &[u8],
) -> Option<(Option<u16>, Option<u16>)> {
let min_len = match protocol {
IP_PROTO_TCP => 20,
IP_PROTO_UDP => 8,
_ => return Some((None, None)),
};
if payload.len() < min_len {
return None;
}
Some((
Some(u16::from_be_bytes([payload[0], payload[1]])),
Some(u16::from_be_bytes([payload[2], payload[3]])),
))
}
+18 -4
View File
@@ -1,7 +1,11 @@
pub(crate) mod compressor;
pub(crate) mod flow;
mod hole_punch;
pub(crate) mod ip;
pub mod stun;
pub use flow::FlowKey;
pub(crate) use hole_punch::{
HOLE_PUNCH_PACKET_BODY_LEN, hole_punch_packet_tid, new_hole_punch_packet,
};
@@ -539,6 +543,8 @@ impl ZCPacketType {
pub struct ZCPacket {
inner: BytesMut,
packet_type: ZCPacketType,
/// Local scheduling metadata; never serialized into a tunnel packet.
pub(crate) flow_key: Option<FlowKey>,
}
impl ZCPacket {
@@ -554,6 +560,7 @@ impl ZCPacket {
Self {
inner: BytesMut::new(),
packet_type: ZCPacketType::NIC,
flow_key: None,
}
}
@@ -561,6 +568,7 @@ impl ZCPacket {
Self {
inner: buf,
packet_type,
flow_key: None,
}
}
@@ -608,6 +616,7 @@ impl ZCPacket {
.copy_from_slice(foreign_zc_packet.tunnel_payload());
ret.fill_peer_manager_hdr(0, 0, PacketType::ForeignNetworkPacket as u8);
ret.flow_key = foreign_zc_packet.flow_key;
ret
}
@@ -783,11 +792,14 @@ impl ZCPacket {
let mut buf = BytesMut::with_capacity(new_pm_offset + tunnel_payload.len());
unsafe { buf.set_len(new_pm_offset) };
buf.extend_from_slice(tunnel_payload);
return Self::new_from_buf(buf, target_packet_type);
let mut packet = Self::new_from_buf(buf, target_packet_type);
packet.flow_key = self.flow_key;
return packet;
}
self.inner.advance(new_offset);
Self::new_from_buf(self.inner, target_packet_type)
self.packet_type = target_packet_type;
self
}
pub fn into_bytes(self) -> Bytes {
@@ -839,13 +851,15 @@ impl ZCPacket {
let hdr = self.foreign_network_hdr().unwrap();
let foreign_hdr_len = hdr.get_header_len();
Self::new_from_buf(
let mut packet = Self::new_from_buf(
{
self.inner.advance(foreign_hdr_len + self.payload_offset());
self.inner
},
ZCPacketType::DummyTunnel,
)
);
packet.flow_key = self.flow_key;
packet
}
pub fn get_src_peer_id(&self) -> Option<u32> {
+15 -178
View File
@@ -12,36 +12,21 @@ use quanta::Instant;
use tokio_util::task::AbortOnDropHandle;
use crate::{
packet::{PacketType, ZCPacket},
packet::{
PacketType, ZCPacket,
ip::{
IP_PROTO_ICMP, IP_PROTO_ICMPV6, IP_PROTO_TCP, IP_PROTO_UDP, IP_PROTO_UNSPECIFIED,
ParsedIpPacket, parse_ip_packet, parse_transport_ports,
},
},
peers::acl::processor::{AclProcessor, AclResult, AclStatKey, AclStatType, PacketInfo},
};
const IP_PROTO_ICMP: u8 = 1;
const IP_PROTO_TCP: u8 = 6;
const IP_PROTO_UDP: u8 = 17;
const IP_PROTO_ICMPV6: u8 = 58;
const IP_PROTO_UNSPECIFIED: u8 = u8::MAX;
const ICMP_ECHO_REPLY: u8 = 0;
const ICMP_ECHO_REQUEST: u8 = 8;
const ICMPV6_ECHO_REQUEST: u8 = 128;
const ICMPV6_ECHO_REPLY: u8 = 129;
const IPV6_HOP_BY_HOP: u8 = 0;
const IPV6_ROUTING: u8 = 43;
const IPV6_FRAGMENT: u8 = 44;
const IPV6_AUTHENTICATION: u8 = 51;
const IPV6_DESTINATION_OPTIONS: u8 = 60;
#[derive(Clone, Copy)]
struct ParsedIpPacket<'a> {
src_ip: IpAddr,
dst_ip: IpAddr,
protocol: u8,
transport_payload: &'a [u8],
is_non_initial_fragment: bool,
}
impl ParsedIpPacket<'_> {
fn can_use_allow_record(&self, is_in: bool) -> bool {
let expected_type = match (self.protocol, is_in) {
@@ -64,159 +49,6 @@ impl ParsedIpPacket<'_> {
}
}
fn parse_ip_packet(payload: &[u8]) -> Option<ParsedIpPacket<'_>> {
let version = payload.first()? >> 4;
match version {
4 => parse_ipv4_packet(payload),
6 => parse_ipv6_packet(payload),
_ => None,
}
}
fn parse_ipv4_packet(payload: &[u8]) -> Option<ParsedIpPacket<'_>> {
if payload.len() < 20 {
return None;
}
let header_len = usize::from(payload[0] & 0x0f) * 4;
let options_len = header_len.saturating_sub(20);
let payload_offset = 20 + options_len;
let payload_start = payload_offset.min(payload.len());
let total_length = usize::from(u16::from_be_bytes([payload[2], payload[3]]));
let payload_len = total_length.saturating_sub(header_len);
let payload_end = payload_start.saturating_add(payload_len).min(payload.len());
Some(ParsedIpPacket {
src_ip: IpAddr::V4(Ipv4Addr::new(
payload[12],
payload[13],
payload[14],
payload[15],
)),
dst_ip: IpAddr::V4(Ipv4Addr::new(
payload[16],
payload[17],
payload[18],
payload[19],
)),
protocol: payload[9],
transport_payload: payload.get(payload_start..payload_end)?,
is_non_initial_fragment: u16::from_be_bytes([payload[6], payload[7]]) & 0x1fff != 0,
})
}
fn parse_ipv6_packet(payload: &[u8]) -> Option<ParsedIpPacket<'_>> {
if payload.len() < 40 {
return None;
}
// A valid base header still supplies the addresses needed for ACL rules,
// even when the upper-layer headers cannot be decoded.
let parsed = ParsedIpPacket {
src_ip: IpAddr::V6(Ipv6Addr::from(<[u8; 16]>::try_from(&payload[8..24]).ok()?)),
dst_ip: IpAddr::V6(Ipv6Addr::from(<[u8; 16]>::try_from(&payload[24..40]).ok()?)),
protocol: IP_PROTO_UNSPECIFIED,
transport_payload: &[],
is_non_initial_fragment: false,
};
let payload_len = usize::from(u16::from_be_bytes([payload[4], payload[5]]));
let payload_end = 40usize.saturating_add(payload_len).min(payload.len());
let mut protocol = payload[6];
let mut payload_offset = 40;
let mut is_non_initial_fragment = false;
let mut saw_fragment_header = false;
loop {
match protocol {
IPV6_HOP_BY_HOP | IPV6_ROUTING | IPV6_DESTINATION_OPTIONS => {
let header = &payload[payload_offset..payload_end];
let Some(&header_len) = header.get(1) else {
return Some(parsed);
};
let extension_len = (usize::from(header_len) + 1) * 8;
let next_protocol = header[0];
let extension_end = payload_offset + extension_len;
if extension_end > payload_end {
return Some(parsed);
}
protocol = next_protocol;
payload_offset = extension_end;
}
IPV6_AUTHENTICATION => {
let header = &payload[payload_offset..payload_end];
let Some(&header_len) = header.get(1) else {
return Some(parsed);
};
let extension_len = (usize::from(header_len) + 2) * 4;
let next_protocol = header[0];
let extension_end = payload_offset + extension_len;
if extension_end > payload_end {
return Some(parsed);
}
protocol = next_protocol;
payload_offset = extension_end;
}
IPV6_FRAGMENT => {
if saw_fragment_header {
return Some(parsed);
}
saw_fragment_header = true;
let fragment_end = payload_offset + 8;
if fragment_end > payload_end {
return Some(parsed);
}
let header = &payload[payload_offset..fragment_end];
let fragment_field = u16::from_be_bytes([header[2], header[3]]);
is_non_initial_fragment = fragment_field & 0xfff8 != 0;
let next_protocol = header[0];
payload_offset = fragment_end;
protocol = next_protocol;
if is_non_initial_fragment {
// Extension headers after the Fragment Header are only in the first
// fragment. Do not interpret tail data as an extension header.
// Only ICMPv6 tails can use the response records without a
// transport header. Leave other tails to address/group ACLs.
if protocol != IP_PROTO_ICMPV6 {
protocol = IP_PROTO_UNSPECIFIED;
}
break;
}
}
_ => break,
}
}
let transport_payload = &payload[payload_offset..payload_end];
if payload_offset != 40 && parse_transport_ports(protocol, transport_payload).is_none() {
return Some(parsed);
}
Some(ParsedIpPacket {
protocol,
transport_payload,
is_non_initial_fragment,
..parsed
})
}
fn parse_transport_ports(protocol: u8, payload: &[u8]) -> Option<(Option<u16>, Option<u16>)> {
let min_len = match protocol {
IP_PROTO_TCP => 20,
IP_PROTO_UDP => 8,
_ => return Some((None, None)),
};
if payload.len() < min_len {
return None;
}
Some((
Some(u16::from_be_bytes([payload[0], payload[1]])),
Some(u16::from_be_bytes([payload[2], payload[3]])),
))
}
fn acl_protocol(protocol: u8) -> Protocol {
match protocol {
IP_PROTO_TCP => Protocol::Tcp,
@@ -568,13 +400,18 @@ mod tests {
use easytier_proto::acl::{Acl, AclV1, Action, Chain, ChainType, Protocol, Rule};
use crate::peers::acl::processor::PacketInfo;
use crate::{
packet::ip::{
IPV6_AUTHENTICATION, IPV6_DESTINATION_OPTIONS, IPV6_FRAGMENT, IPV6_HOP_BY_HOP,
IPV6_ROUTING,
},
peers::acl::processor::PacketInfo,
};
use super::{
AclFilter, ICMP_ECHO_REPLY, ICMP_ECHO_REQUEST, ICMPV6_ECHO_REPLY, ICMPV6_ECHO_REQUEST,
IP_PROTO_ICMP, IP_PROTO_ICMPV6, IP_PROTO_TCP, IP_PROTO_UDP, IP_PROTO_UNSPECIFIED,
IPV6_AUTHENTICATION, IPV6_DESTINATION_OPTIONS, IPV6_FRAGMENT, IPV6_HOP_BY_HOP,
IPV6_ROUTING, OutboundAllowRecord, PacketType, ZCPacket, acl_protocol, parse_ip_packet,
OutboundAllowRecord, PacketType, ZCPacket, acl_protocol, parse_ip_packet,
parse_transport_ports,
};
+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,
+1
View File
@@ -5,5 +5,6 @@ pub(crate) mod peer;
pub(crate) mod peer_conn;
pub(crate) mod peer_conn_liveness;
pub(crate) mod peer_conn_ping;
mod peer_flow;
pub(crate) mod peer_map;
pub(crate) mod peer_session;
+341 -29
View File
@@ -1,7 +1,13 @@
use std::sync::Arc;
use std::{
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
time::{Duration, Instant},
};
use arc_swap::ArcSwapOption;
use crossbeam::atomic::AtomicCell;
use crossbeam_utils::atomic::AtomicCell;
use dashmap::{DashMap, DashSet};
use parking_lot::{Mutex, RwLock};
@@ -9,7 +15,10 @@ use tokio::{select, sync::mpsc};
use tracing::Instrument;
use super::peer_conn::{PeerConn, PeerConnId};
use super::{
peer_conn::{PeerConn, PeerConnId},
peer_flow::FlowBindings,
};
use crate::peers::{
PacketRecvChan,
context::{ArcPeerContext, PeerEvent},
@@ -17,7 +26,8 @@ use crate::peers::{
};
use crate::{
config::PeerId,
packet::ZCPacket,
foundation::small_map_shard_amount,
packet::{FlowKey, ZCPacket},
peers::error::Error,
proto::{core_peer::peer::PeerConnInfo, peer_rpc::PeerIdentityType},
};
@@ -26,8 +36,19 @@ use tokio_util::task::AbortOnDropHandle;
type ArcPeerConn = Arc<PeerConn>;
type ConnMap = Arc<DashMap<PeerConnId, ArcPeerConn>>;
fn conn_latency_sort_key(latency_us: u64, is_hole_punched: bool) -> (bool, u64) {
(is_hole_punched && latency_us == 0, latency_us)
fn conn_selection_key(conn: &PeerConn) -> (u8, u64) {
let latency = conn.get_stats().latency_us;
(
if latency > 0 {
0
} else if conn.is_hole_punched() {
// Prefer regular connections until hole punching has a measured RTT.
2
} else {
1
},
latency,
)
}
pub struct Peer {
@@ -45,6 +66,8 @@ pub struct Peer {
default_conn: Arc<ArcSwapOption<PeerConn>>,
default_conn_update_lock: Arc<Mutex<()>>,
flow_bindings: Mutex<FlowBindings>,
data_active: Arc<AtomicBool>,
peer_identity_type: Arc<AtomicCell<Option<PeerIdentityType>>>,
peer_public_key: Arc<RwLock<Option<Vec<u8>>>>,
#[allow(dead_code)]
@@ -57,7 +80,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));
@@ -66,6 +89,8 @@ impl Peer {
let peer_public_key_copy = peer_public_key.clone();
let default_conn = Arc::new(ArcSwapOption::empty());
let default_conn_update_lock = Arc::new(Mutex::new(()));
let data_active = Arc::new(AtomicBool::new(false));
let flow_bindings = Mutex::new(FlowBindings::default());
let conns_copy = conns.clone();
let shutdown_notifier_copy = shutdown_notifier.clone();
@@ -130,12 +155,18 @@ impl Peer {
let conns_copy = conns.clone();
let default_conn_copy = default_conn.clone();
let data_active_copy = data_active.clone();
let default_conn_clear_task = AbortOnDropHandle::new(tokio::spawn(async move {
loop {
crate::foundation::time::sleep(std::time::Duration::from_secs(5)).await;
crate::foundation::time::sleep(Duration::from_secs(5)).await;
if conns_copy.len() > 1 {
default_conn_copy.store(None);
}
let active = data_active_copy.swap(false, Ordering::Relaxed);
let fast_ping = conns_copy.len() > 1 && active;
for conn in conns_copy.iter() {
conn.fast_ping.store(fast_ping, Ordering::Relaxed);
}
}
}));
@@ -151,6 +182,8 @@ impl Peer {
shutdown_notifier,
default_conn,
default_conn_update_lock,
flow_bindings,
data_active,
peer_identity_type,
peer_public_key,
default_conn_clear_task,
@@ -212,22 +245,19 @@ impl Peer {
fn select_conn(&self) -> Option<ArcPeerConn> {
let _update_guard = self.default_conn_update_lock.lock();
if let Some(conn) = self.default_conn.load_full() {
if let Some(conn) = self.default_conn.load_full()
&& !conn.is_closed()
{
return Some(conn);
}
// A zero latency on a hole-punched connection means the ping loop has not
// confirmed liveness yet. Prefer any other connection, so a freshly admitted
// hole-punched path cannot steal traffic before its first successful ping.
// Unmeasured paths are usable for startup/recovery, but cannot outrank a
// measured path merely because their RTT is still zero.
let selected = self
.conns
.iter()
.min_by_key(|conn| {
conn_latency_sort_key(
conn.value().get_stats().latency_us,
conn.value().is_hole_punched(),
)
})
.filter(|conn| !conn.is_closed())
.min_by_key(|conn| conn_selection_key(conn.value()))
.map(|conn| conn.value().clone());
if let Some(conn) = selected.as_ref() {
@@ -236,9 +266,42 @@ impl Peer {
selected
}
fn select_flow_conn(&self, key: FlowKey) -> Option<ArcPeerConn> {
let mut flows = self.flow_bindings.lock();
let bucket = flows.bucket(key);
if let Some(binding) = flows.buckets.get_mut(&bucket)
&& binding.last_active.elapsed() < FlowBindings::IDLE_TIMEOUT
{
let bound = self
.conns
.get(&binding.conn_id)
.map(|conn| conn.value().clone());
if let Some(conn) = bound.filter(|conn| !conn.is_closed()) {
binding.last_active = Instant::now();
return Some(conn);
}
}
let conn = self.select_conn()?;
// A collision joins the bucket's active binding rather than evicting an
// existing flow. This bounds memory without migrating flows under load.
flows.bind(bucket, conn.get_conn_id(), Instant::now());
Some(conn)
}
pub async fn send_msg(&self, msg: ZCPacket) -> Result<(), Error> {
if crate::peers::traffic_metrics::data_packet_payload_len(&msg).is_some() {
self.data_active.store(true, Ordering::Relaxed);
}
if let Some(key) = msg.flow_key {
let conn = self
.select_flow_conn(key)
.ok_or(Error::PeerNoConnectionError(self.peer_node_id))?;
return conn.send_msg(msg).await;
}
let default_conn = self.default_conn.load();
if let Some(conn) = default_conn.as_ref() {
if let Some(conn) = default_conn.as_ref()
&& !conn.is_closed()
{
conn.send_msg(msg).await?;
return Ok(());
}
@@ -338,20 +401,269 @@ impl Drop for Peer {
#[cfg(test)]
mod tests {
use super::conn_latency_sort_key;
use super::*;
use crate::{
packet::PacketType,
peers::{
PeerConnectionOrigin, conn::peer_session::PeerSessionStore, create_packet_recv_chan,
recv_packet_envelope_from_chan, test_support::NoopPeerContext,
},
tunnel::ring::create_ring_tunnel_pair,
};
#[test]
fn measured_relay_precedes_unverified_hole_punch_path() {
assert!(conn_latency_sort_key(20_000, false) < conn_latency_sort_key(0, true));
fn flow(port: u16) -> FlowKey {
FlowKey {
src: ([10, 0, 0, 1], port).into(),
dst: ([10, 0, 0, 2], 5000).into(),
protocol: 17,
}
}
#[test]
fn unmeasured_regular_connection_keeps_existing_priority() {
assert!(conn_latency_sort_key(0, false) < conn_latency_sort_key(20_000, false));
async fn add_connection(
peer: &Peer,
recv: PacketRecvChan,
rtt: Option<u32>,
) -> (PeerConnId, PeerConn) {
let (left, right) = create_ring_tunnel_pair();
let sessions = Arc::new(PeerSessionStore::new());
let context = Arc::new(NoopPeerContext::default());
let mut client = PeerConn::new(1, context.clone(), left, sessions.clone());
let mut server = PeerConn::new(2, context, right, sessions);
let (left, right) = tokio::join!(
client.do_handshake_as_client(),
server.do_handshake_as_server_ext(|_, _| Ok(())),
);
left.unwrap();
right.unwrap();
server.start_recv_loop(recv).await;
if let Some(rtt) = rtt {
client.record_latency_for_test(rtt);
}
let id = client.get_conn_id();
peer.add_peer_conn(client).await.unwrap();
(id, server)
}
#[test]
fn verified_lower_latency_path_can_be_preferred() {
assert!(conn_latency_sort_key(5_000, true) < conn_latency_sort_key(20_000, false));
fn new_peer() -> Peer {
let (sender, _receiver) = create_packet_recv_chan();
Peer::new(2, sender, Arc::new(NoopPeerContext::default()))
}
#[tokio::test]
async fn send_failure_marks_connection_closed_without_waiting_for_ping() {
let (left, right) = create_ring_tunnel_pair();
let (stream, _sink) = right.split();
drop(stream);
let conn = PeerConn::new(
1,
Arc::new(NoopPeerContext::default()),
left,
Arc::new(PeerSessionStore::new()),
);
crate::foundation::time::timeout(Duration::from_secs(1), async {
loop {
let mut packet = ZCPacket::new_with_payload(b"closed sink");
packet.fill_peer_manager_hdr(1, 2, PacketType::Data as u8);
if conn.send_msg(packet).await.is_err() {
break;
}
tokio::task::yield_now().await;
}
})
.await
.unwrap();
assert!(conn.is_closed());
}
async fn packet_skips_failed_connection_before_cache_cleanup(flow_key: Option<FlowKey>) {
let peer = new_peer();
let (sender, mut receiver) = create_packet_recv_chan();
let (old, old_server) = add_connection(&peer, sender.clone(), Some(20_000)).await;
let (_, new_server) = add_connection(&peer, sender, Some(1_000)).await;
let old_conn = peer.conns.get(&old).unwrap().value().clone();
peer.default_conn.store(Some(old_conn.clone()));
if let Some(key) = flow_key {
assert_eq!(peer.select_flow_conn(key).unwrap().get_conn_id(), old);
}
// Keep the stale cache entry so the result cannot depend on task ordering.
peer.close_event_listener.abort();
drop(old_server);
crate::foundation::time::timeout(Duration::from_secs(1), async {
loop {
let mut packet = ZCPacket::new_with_payload(b"closed sink");
packet.fill_peer_manager_hdr(1, 2, PacketType::Data as u8);
if old_conn.send_msg(packet).await.is_err() {
break;
}
tokio::task::yield_now().await;
}
})
.await
.unwrap();
assert!(old_conn.is_closed());
assert_eq!(peer.get_default_conn_id(), old);
let mut packet = ZCPacket::new_with_payload(b"packet after failure");
packet.fill_peer_manager_hdr(1, 2, PacketType::Data as u8);
packet.flow_key = flow_key;
peer.send_msg(packet).await.unwrap();
let envelope = crate::foundation::time::timeout(
Duration::from_secs(1),
recv_packet_envelope_from_chan(&mut receiver),
)
.await
.unwrap()
.unwrap();
let (packet, ingress) = envelope.into_parts();
assert_eq!(packet.payload(), b"packet after failure");
assert_eq!(
ingress.peer_connection().unwrap().1,
new_server.get_conn_id()
);
}
#[tokio::test]
async fn unkeyed_packet_skips_failed_connection_before_cache_cleanup() {
packet_skips_failed_connection_before_cache_cleanup(None).await;
}
#[tokio::test]
async fn active_flow_skips_failed_connection_before_cache_cleanup() {
packet_skips_failed_connection_before_cache_cleanup(Some(flow(1234))).await;
}
#[tokio::test]
async fn unmeasured_regular_connection_precedes_unverified_hole_punch() {
let (tunnel, _remote) = create_ring_tunnel_pair();
let regular = PeerConn::new(
1,
Arc::new(NoopPeerContext::default()),
tunnel,
Arc::new(PeerSessionStore::new()),
);
for origin in [
PeerConnectionOrigin::TcpHolePunch,
PeerConnectionOrigin::UdpHolePunch,
] {
let (tunnel, _remote) = create_ring_tunnel_pair();
let hole_punched = PeerConn::new_with_peer_id_hint_and_origin(
1,
Arc::new(NoopPeerContext::default()),
tunnel,
None,
Arc::new(PeerSessionStore::new()),
origin,
);
assert!(conn_selection_key(&regular) < conn_selection_key(&hole_punched));
hole_punched.record_latency_for_test(1_000);
assert!(conn_selection_key(&hole_punched) < conn_selection_key(&regular));
}
}
fn distinct_flow(peer: &Peer, existing: FlowKey) -> FlowKey {
let flows = peer.flow_bindings.lock();
(1..=u16::MAX)
.map(flow)
.find(|key| flows.bucket(*key) != flows.bucket(existing))
.unwrap()
}
#[tokio::test]
async fn active_flow_survives_new_connection_and_periodic_reselection() {
let peer = new_peer();
let (sender, mut receiver) = create_packet_recv_chan();
let (old, old_server) = add_connection(&peer, sender.clone(), Some(20_000)).await;
let key = flow(1234);
assert_eq!(peer.select_flow_conn(key).unwrap().get_conn_id(), old);
let (new, new_server) = add_connection(&peer, sender, Some(1_000)).await;
// A slow but open connection must keep its existing flow.
for _ in 0..15 {
peer.conns
.get(&old)
.unwrap()
.record_latency_for_test(500_000);
}
peer.default_conn.store(None);
assert_eq!(peer.select_flow_conn(key).unwrap().get_conn_id(), old);
let new_key = distinct_flow(&peer, key);
assert_eq!(peer.select_flow_conn(new_key).unwrap().get_conn_id(), new);
for (key, expected) in [
(key, old_server.get_conn_id()),
(new_key, new_server.get_conn_id()),
] {
let mut packet = ZCPacket::new_with_payload(b"business data");
packet.fill_peer_manager_hdr(1, 2, PacketType::Data as u8);
packet.flow_key = Some(key);
peer.send_msg(packet).await.unwrap();
let envelope = crate::foundation::time::timeout(
Duration::from_secs(1),
recv_packet_envelope_from_chan(&mut receiver),
)
.await
.unwrap()
.unwrap();
let (packet, ingress) = envelope.into_parts();
assert_eq!(packet.payload(), b"business data");
assert_eq!(ingress.peer_connection().unwrap().1, expected);
}
}
#[tokio::test]
async fn idle_flow_can_rebind_and_closed_connection_cannot_trap_active_flow() {
let peer = new_peer();
let (sender, _receiver) = create_packet_recv_chan();
let (old, _old_server) = add_connection(&peer, sender.clone(), Some(20_000)).await;
let key = flow(1234);
assert_eq!(peer.select_flow_conn(key).unwrap().get_conn_id(), old);
let (new, _new_server) = add_connection(&peer, sender, Some(1_000)).await;
peer.default_conn.store(None);
// Activity renews the idle timeout even across repeated intervals.
for _ in 0..2 {
{
let mut flows = peer.flow_bindings.lock();
let bucket = flows.bucket(key);
flows.buckets.get_mut(&bucket).unwrap().last_active -=
FlowBindings::IDLE_TIMEOUT - Duration::from_secs(1);
}
assert_eq!(peer.select_flow_conn(key).unwrap().get_conn_id(), old);
}
{
let mut flows = peer.flow_bindings.lock();
let bucket = flows.bucket(key);
flows.buckets.get_mut(&bucket).unwrap().last_active -= FlowBindings::IDLE_TIMEOUT;
}
assert_eq!(peer.select_flow_conn(key).unwrap().get_conn_id(), new);
peer.close_peer_conn(&new).await.unwrap();
crate::foundation::time::timeout(Duration::from_secs(1), async {
while peer.conns.contains_key(&new) {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
assert_eq!(peer.select_flow_conn(key).unwrap().get_conn_id(), old);
}
#[tokio::test]
async fn bucket_collisions_and_capacity_never_evict_an_active_binding() {
let peer = new_peer();
let (sender, _receiver) = create_packet_recv_chan();
let (old, _old_server) = add_connection(&peer, sender.clone(), Some(20_000)).await;
for port in 1..=u16::MAX {
assert_eq!(
peer.select_flow_conn(flow(port)).unwrap().get_conn_id(),
old
);
}
assert!(peer.flow_bindings.lock().buckets.len() <= FlowBindings::BUCKETS);
let (_new, _new_server) = add_connection(&peer, sender, Some(1_000)).await;
peer.default_conn.store(None);
for port in 1..=u16::MAX {
assert_eq!(
peer.select_flow_conn(flow(port)).unwrap().get_conn_id(),
old
);
}
}
}
+25 -9
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,
@@ -7,7 +7,7 @@ use std::{
pin::Pin,
sync::{
Arc,
atomic::{AtomicU32, Ordering},
atomic::{AtomicBool, AtomicU32, Ordering},
},
};
@@ -298,6 +298,7 @@ pub struct PeerConn {
ctrl_resp_sender: broadcast::Sender<ZCPacket>,
latency_stats: Arc<WindowLatency>,
pub(super) fast_ping: Arc<AtomicBool>,
throughput: Arc<Throughput>,
loss_rate_stats: Arc<AtomicU32>,
liveness: PeerConnLiveness,
@@ -398,6 +399,7 @@ impl PeerConn {
ctrl_resp_sender: ctrl_sender,
latency_stats: Arc::new(WindowLatency::new(15)),
fast_ping: Arc::new(AtomicBool::new(false)),
throughput,
loss_rate_stats: Arc::new(AtomicU32::new(0)),
liveness,
@@ -1385,22 +1387,31 @@ impl PeerConn {
self.liveness.clone(),
self.max_ping_interval(),
);
pingpong.fast_ping = self.fast_ping.clone();
let close_event_notifier = self.close_event_notifier.clone();
let conn_id = self.conn_id;
self.tasks.spawn(async move {
pingpong.pingpong().await;
self.tasks.spawn(
async move {
pingpong.pingpong().await;
tracing::warn!(?pingpong, "pingpong task exit");
tracing::warn!(?pingpong, "pingpong task exit");
close_event_notifier.notify_close();
close_event_notifier.notify_close();
Ok(())
});
Ok(())
}
.instrument(tracing::info_span!("peer_conn_pingpong", ?conn_id)),
);
}
pub async fn send_msg(&self, msg: ZCPacket) -> Result<(), Error> {
Ok(self.sink.send(msg).await?)
if let Err(error) = self.sink.send(msg).await {
self.close_event_notifier.notify_close();
return Err(error.into());
}
Ok(())
}
pub fn get_peer_id(&self) -> PeerId {
@@ -1459,6 +1470,11 @@ impl PeerConn {
self.close_event_notifier.clone()
}
#[cfg(test)]
pub(super) fn record_latency_for_test(&self, latency_us: u32) {
self.latency_stats.record_latency(latency_us);
}
pub fn get_stats(&self) -> PeerConnStats {
PeerConnStats {
latency_us: self.latency_stats.get_latency_us(),
+76 -2
View File
@@ -1,7 +1,7 @@
use std::{
sync::{
Arc,
atomic::{AtomicU32, Ordering},
atomic::{AtomicBool, AtomicU32, Ordering},
},
time::{Duration, Instant},
};
@@ -124,6 +124,7 @@ impl PingIntervalController {
}
pub struct PeerConnPinger {
pub(super) fast_ping: Arc<AtomicBool>,
my_peer_id: PeerId,
peer_id: PeerId,
sink: MpscTunnelSender,
@@ -163,6 +164,7 @@ impl PeerConnPinger {
) -> Self {
Self {
my_peer_id,
fast_ping: Arc::new(AtomicBool::new(false)),
peer_id,
sink,
latency_stats,
@@ -188,13 +190,13 @@ impl PeerConnPinger {
seq: u32,
liveness_token: Option<u8>,
) -> Result<PingResponse, Error> {
let now = Instant::now();
// should add seq here. so latency can be calculated more accurately
let req = Self::new_ping_packet(self.my_peer_id, self.peer_id, seq);
let req_len = req.buf_len() as u64;
self.sink.send(req).await?;
self.context.record_control_tx(&self.network_name, req_len);
let now = Instant::now();
let wait_for_pong = async {
loop {
match receiver.recv().await {
@@ -257,11 +259,17 @@ impl PeerConnPinger {
let throughput = self.throughput_stats.clone();
let controller_loss_counter = loss_counter.clone();
let max_interval = self.max_interval;
let fast_ping = self.fast_ping.clone();
controller_tasks.spawn(async move {
let mut controller =
PingIntervalController::new(throughput, controller_loss_counter, max_interval);
loop {
controller.tick().await;
controller.max_interval = if fast_ping.load(Ordering::Relaxed) {
Duration::from_secs(1)
} else {
max_interval
};
if !controller.should_send_ping() {
continue;
}
@@ -290,6 +298,8 @@ impl PeerConnPinger {
req_seq = req_seq.wrapping_add(1);
if let Ok(response) = &ret {
// A liveness echo proves reachability, not prompt delivery of a
// queued packet. Only a matching Pong supplies an RTT sample.
if let PingResponse::Pong(lat) = response {
self.latency_stats.record_latency(*lat as u32);
}
@@ -350,6 +360,70 @@ mod tests {
},
};
#[tokio::test]
async fn pong_latency_includes_waiting_for_send_queue_capacity() {
use crate::tunnel::ring::{RingTunnel, create_ring_socket_pair};
let (left, right) = create_ring_socket_pair(1);
let tunnel = MpscTunnel::new(RingTunnel::new(left, None), None);
let remote = RingTunnel::new(right, None);
let (mut stream, _sink) = remote.split();
let sender = tunnel.get_sink();
// Fill both the underlying ring and the MPSC queue without draining.
assert!(
timeout(Duration::from_millis(10), async {
loop {
sender
.send(ZCPacket::new_with_payload(b"queued business"))
.await
.unwrap();
}
})
.await
.is_err()
);
let (ctrl_sender, mut receiver) = broadcast::channel(16);
let pinger = PeerConnPinger::new(
1,
2,
sender,
ctrl_sender.clone(),
Arc::new(WindowLatency::new(15)),
Arc::new(AtomicU32::new(0)),
Arc::new(Throughput::new()),
Arc::new(NoopPeerContext::default()),
"test".to_owned(),
PeerConnLiveness::new(),
Duration::from_secs(1),
);
let probe = pinger.do_pingpong_once(&mut receiver, 1, None);
tokio::pin!(probe);
assert!(
timeout(Duration::from_millis(50), &mut probe)
.await
.is_err()
);
let reply = tokio::spawn(async move {
while let Some(Ok(packet)) = stream.next().await {
if packet.peer_manager_header().unwrap().packet_type == PacketType::Ping as u8 {
ctrl_sender.send(packet).unwrap();
return;
}
}
panic!("queued Ping was not delivered");
});
let PingResponse::Pong(rtt) = timeout(Duration::from_secs(1), probe)
.await
.unwrap()
.unwrap()
else {
panic!("expected a measured Pong");
};
assert!(rtt >= 50_000, "send queue wait was excluded: {rtt} us");
reply.await.unwrap();
}
#[cfg(not(target_os = "wasi"))]
#[tokio::test(start_paused = true)]
async fn one_second_limit_disables_ping_backoff() {
+41
View File
@@ -0,0 +1,41 @@
//! Bounded flow bindings retained until idle or their connection closes.
use std::{
collections::{HashMap, hash_map::RandomState},
hash::BuildHasher,
time::{Duration, Instant},
};
use crate::packet::FlowKey;
use super::peer_conn::PeerConnId;
pub(super) struct FlowBinding {
pub conn_id: PeerConnId,
pub last_active: Instant,
}
#[derive(Default)]
pub(super) struct FlowBindings {
hash: RandomState,
pub buckets: HashMap<usize, FlowBinding>,
}
impl FlowBindings {
pub const BUCKETS: usize = 4096;
pub const IDLE_TIMEOUT: Duration = Duration::from_secs(120);
pub fn bucket(&self, key: FlowKey) -> usize {
self.hash.hash_one(key) as usize % Self::BUCKETS
}
pub fn bind(&mut self, bucket: usize, conn_id: PeerConnId, now: Instant) {
self.buckets.insert(
bucket,
FlowBinding {
conn_id,
last_active: now,
},
);
}
}
+42 -7
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())),
@@ -452,12 +453,12 @@ pub(crate) async fn direct_peer_info(peer_maps: &[Arc<PeerMap>]) -> PeerInfoForG
break;
}
}
let Some(min_lat) = conns
.into_iter()
.flatten()
.map(|conn| conn.stats.as_ref().unwrap().latency_us)
.min()
else {
let Some(min_lat) = min_measured_latency_us(
conns
.into_iter()
.flatten()
.map(|conn| conn.stats.as_ref().unwrap().latency_us),
) else {
continue;
};
@@ -470,3 +471,37 @@ pub(crate) async fn direct_peer_info(peer_maps: &[Arc<PeerMap>]) -> PeerInfoForG
}
ret
}
/// Lowest latency among the connections that already have a latency sample.
///
/// A freshly established connection reports `latency_us == 0` until its first
/// ping completes. Treating that as a real value would advertise the peer as
/// 1 ms away to the peer center, so latency-first routing could detour through
/// a slow or lossy link until the next report. Unmeasured connections are
/// skipped instead; a peer with none measured is left out of the report and is
/// reported once its first sample arrives, because the peer list changes then.
fn min_measured_latency_us(latencies_us: impl IntoIterator<Item = u64>) -> Option<u64> {
latencies_us.into_iter().filter(|lat| *lat > 0).min()
}
#[cfg(test)]
mod tests {
use super::min_measured_latency_us;
#[test]
fn unmeasured_connections_are_not_reported_as_fastest() {
assert_eq!(min_measured_latency_us([0, 306_000]), Some(306_000));
assert_eq!(min_measured_latency_us([306_000, 0, 93_000]), Some(93_000));
}
#[test]
fn peer_without_measured_connection_is_skipped() {
assert_eq!(min_measured_latency_us([0, 0]), None);
assert_eq!(min_measured_latency_us([]), None);
}
#[test]
fn sub_millisecond_lan_latency_is_still_reported() {
assert_eq!(min_measured_latency_us([400]), Some(400));
}
}
+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,
)
}

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