mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-10-29 08:03:54 +00:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ed7fcece6 | ||
|
|
4bdba58ca9 | ||
|
|
6487a5e463 | ||
|
|
48ccc56a1a | ||
|
|
b974acd737 | ||
|
|
3463190838 | ||
|
|
cf6531b302 | ||
|
|
4ae9deb72b | ||
|
|
6a5507b41e | ||
|
|
dccb85ab36 | ||
|
|
4a91eeb379 | ||
|
|
79dd97dd7f | ||
|
|
2eca40c56a | ||
|
|
fe18f32c8b | ||
|
|
5565251562 | ||
|
|
ffcfba4afd | ||
|
|
7f2e9dddf2 | ||
|
|
887c701ee8 | ||
|
|
82dd835c26 | ||
|
|
411faae131 | ||
|
|
6d9adc4fb9 | ||
|
|
54f2850d9d | ||
|
|
e307dadb48 | ||
|
|
318a0b0686 | ||
|
|
c7e636f918 | ||
|
|
5af90471bf | ||
|
|
a144597d02 | ||
|
|
4ff27c7ef4 | ||
|
|
f1b910da4e | ||
|
|
e25c6bdd0c | ||
|
|
585628ecf1 | ||
|
|
8c2f67e13e | ||
|
|
c7442868ab | ||
|
|
3384cc1162 |
@@ -16,9 +16,10 @@ 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)'
|
||||
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
|
||||
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
name: Validate BPF veth initialization
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["diagnose/bpf-2710"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
diagnose:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Wait for the fixed test artifact
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
for attempt in $(seq 1 180); do
|
||||
if gh api repos/EasyTier/EasyTier/actions/runs/38869099758/artifacts --jq '.artifacts[].name' | grep -qx tests; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
exit 1
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: tests
|
||||
run-id: 38869099758
|
||||
github-token: ${{ github.token }}
|
||||
- name: Prepare fixed CI binary
|
||||
run: |
|
||||
mkdir -p diagnostics
|
||||
tar --zstd -xf tests.tar.zst
|
||||
python3 - <<'PY'
|
||||
import pathlib, re
|
||||
binaries = [p for p in pathlib.Path('target/debug/deps').iterdir() if re.fullmatch(r'easytier-[0-9a-f]+', p.name)]
|
||||
assert len(binaries) == 1, binaries
|
||||
pathlib.Path('diagnostics/binary-path').write_text(str(binaries[0].resolve()))
|
||||
PY
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq strace tcpdump
|
||||
uname -a > diagnostics/environment.txt
|
||||
systemctl is-active systemd-udevd systemd-networkd NetworkManager >> diagnostics/environment.txt || true
|
||||
sudo ip -ts -s -d monitor link > diagnostics/links.log &
|
||||
sudo tcpdump -i any -U -w diagnostics/frames.pcap 'tcp port 23456' > diagnostics/tcpdump.log 2>&1 &
|
||||
sudo bash -c 'echo "p:bpf_noop noop_enqueue" >> /sys/kernel/tracing/kprobe_events; echo 1 > /sys/kernel/tracing/events/kprobes/bpf_noop/enable' || true
|
||||
sudo cat /sys/kernel/tracing/trace_pipe > diagnostics/kernel-trace.log &
|
||||
- name: Build send observation hook
|
||||
run: |
|
||||
cat > diagnostics/send-observer.c <<'EOF'
|
||||
#define _GNU_SOURCE
|
||||
#include <dlfcn.h>
|
||||
#include <stdio.h>
|
||||
#include <sys/socket.h>
|
||||
#include <netpacket/packet.h>
|
||||
#include <net/if.h>
|
||||
#include <errno.h>
|
||||
#include <time.h>
|
||||
static ssize_t (*real_sendto)(int,const void*,size_t,int,const struct sockaddr*,socklen_t);
|
||||
__attribute__((constructor)) static void initialize(void) {
|
||||
real_sendto = dlsym(RTLD_NEXT, "sendto");
|
||||
}
|
||||
static void read_value(const char *name, const char *key, char *out) {
|
||||
char path[256];
|
||||
snprintf(path, sizeof(path), "/sys/class/net/%s/%s", name, key);
|
||||
FILE *f = fopen(path, "r");
|
||||
if (f) { if (!fgets(out, 64, f)) out[0] = 0; fclose(f); }
|
||||
}
|
||||
ssize_t sendto(int fd, const void *buf, size_t len, int flags,
|
||||
const struct sockaddr *addr, socklen_t addrlen) {
|
||||
ssize_t ret = real_sendto(fd, buf, len, flags, addr, addrlen);
|
||||
int saved_errno = errno;
|
||||
if (addr && addr->sa_family == AF_PACKET) {
|
||||
const struct sockaddr_ll *ll = (const struct sockaddr_ll *)addr;
|
||||
char name[IF_NAMESIZE] = {0}, drops[64] = {0}, state[64] = {0}, carrier[64] = {0};
|
||||
char packets[64] = {0};
|
||||
struct timespec timestamp;
|
||||
clock_gettime(CLOCK_REALTIME, ×tamp);
|
||||
if_indextoname(ll->sll_ifindex, name);
|
||||
read_value(name, "statistics/tx_dropped", drops);
|
||||
read_value(name, "operstate", state);
|
||||
read_value(name, "carrier", carrier);
|
||||
read_value(name, "statistics/tx_packets", packets);
|
||||
fprintf(stderr, "SEND_OBSERVE time=%ld.%09ld dev=%s len=%zu ret=%zd errno=%d tx_dropped=%s operstate=%s carrier=%s tx_packets=%s\n", timestamp.tv_sec, timestamp.tv_nsec, name, len, ret, saved_errno, drops, state, carrier, packets);
|
||||
}
|
||||
errno = saved_errno;
|
||||
return ret;
|
||||
}
|
||||
EOF
|
||||
gcc -shared -fPIC -O2 diagnostics/send-observer.c -ldl -o diagnostics/send-observer.so
|
||||
- name: Repeat fixed test with post-send observation
|
||||
run: |
|
||||
sudo -E python3 -u - <<'PY'
|
||||
import os, pathlib, subprocess, time
|
||||
root = pathlib.Path.cwd()
|
||||
env = os.environ.copy()
|
||||
env['EASYTIER_LINUX_BPF_INTEGRATION'] = 'required'
|
||||
env['LD_PRELOAD'] = str(root / 'diagnostics/send-observer.so')
|
||||
binary = (root / 'diagnostics/binary-path').read_text()
|
||||
case = 'socket::fake_tcp::netfilter::linux_bpf::tests::linux_bpf_tun_sends_ipv4_frame_to_ethernet_interface'
|
||||
start = time.monotonic()
|
||||
failures = 0
|
||||
timeouts = 0
|
||||
for i in range(1, 21):
|
||||
result = subprocess.run([binary, case, '--exact', '--nocapture'], env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
if i <= 3 or result.returncode:
|
||||
(root / f'diagnostics/attempt-{i}.log').write_text(result.stdout)
|
||||
if result.returncode:
|
||||
failures += 1
|
||||
print('REPRODUCED', i, 'elapsed', time.monotonic() - start, flush=True)
|
||||
print(result.stdout, flush=True)
|
||||
if 'timed out waiting for Ethernet frame' in result.stdout:
|
||||
timeouts += 1
|
||||
elif 'assertion `left == right` failed' not in result.stdout:
|
||||
break
|
||||
if timeouts == 3:
|
||||
break
|
||||
if i % 100 == 0:
|
||||
print('PASS', i, 'elapsed', time.monotonic() - start, flush=True)
|
||||
else:
|
||||
print('COMPLETED 20 ATTEMPTS', flush=True)
|
||||
print('TOTAL FAILURES', failures, 'TIMEOUTS', timeouts, flush=True)
|
||||
assert failures == 0
|
||||
PY
|
||||
- name: Repeat fixed binary without observation hooks
|
||||
run: |
|
||||
sudo -E python3 -u - <<'PY'
|
||||
import os, pathlib, subprocess, time
|
||||
root = pathlib.Path.cwd()
|
||||
env = os.environ.copy()
|
||||
env['EASYTIER_LINUX_BPF_INTEGRATION'] = 'required'
|
||||
binary = (root / 'diagnostics/binary-path').read_text()
|
||||
case = 'socket::fake_tcp::netfilter::linux_bpf::tests::linux_bpf_tun_sends_ipv4_frame_to_ethernet_interface'
|
||||
start = time.monotonic()
|
||||
failures = 0
|
||||
for i in range(1, 3001):
|
||||
result = subprocess.run([binary, case, '--exact', '--nocapture'], env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
if result.returncode:
|
||||
failures += 1
|
||||
(root / f'diagnostics/baseline-{i}.log').write_text(result.stdout)
|
||||
if 'timed out waiting for Ethernet frame' in result.stdout:
|
||||
print('ORIGINAL TIMEOUT', i, 'elapsed', time.monotonic() - start, flush=True)
|
||||
print(result.stdout, flush=True)
|
||||
break
|
||||
if 'assertion `left == right` failed' not in result.stdout:
|
||||
print('UNEXPECTED', i, result.stdout, flush=True)
|
||||
break
|
||||
if i % 500 == 0:
|
||||
print('BASELINE', i, 'failures', failures, 'elapsed', time.monotonic() - start, flush=True)
|
||||
else:
|
||||
print('COMPLETED 3000 FIXED ATTEMPTS', flush=True)
|
||||
assert failures == 0
|
||||
PY
|
||||
- name: Validate fixed fixture under worker scheduling pressure
|
||||
run: |
|
||||
cat > diagnostics/cpu-pressure.c <<'EOF'
|
||||
#define _GNU_SOURCE
|
||||
#include <sched.h>
|
||||
#include <stdlib.h>
|
||||
#include <time.h>
|
||||
#include <stdio.h>
|
||||
int main(int argc, char **argv) {
|
||||
cpu_set_t mask;
|
||||
CPU_ZERO(&mask);
|
||||
CPU_SET(atoi(argv[1]), &mask);
|
||||
if (sched_setaffinity(0, sizeof(mask), &mask)) { perror("affinity"); return 1; }
|
||||
struct sched_param param = {.sched_priority = 10};
|
||||
if (sched_setscheduler(0, SCHED_FIFO, ¶m)) { perror("scheduler"); return 1; }
|
||||
struct timespec start, now;
|
||||
clock_gettime(CLOCK_MONOTONIC, &start);
|
||||
do { clock_gettime(CLOCK_MONOTONIC, &now); } while (now.tv_sec - start.tv_sec < 300);
|
||||
return 0;
|
||||
}
|
||||
EOF
|
||||
gcc -O2 diagnostics/cpu-pressure.c -o diagnostics/cpu-pressure
|
||||
sudo -E python3 -u - <<'PY'
|
||||
import os, pathlib, subprocess, time
|
||||
root = pathlib.Path.cwd()
|
||||
env = os.environ.copy()
|
||||
env['EASYTIER_LINUX_BPF_INTEGRATION'] = 'required'
|
||||
env['LD_PRELOAD'] = str(root / 'diagnostics/send-observer.so')
|
||||
binary = (root / 'diagnostics/binary-path').read_text()
|
||||
case = 'socket::fake_tcp::netfilter::linux_bpf::tests::linux_bpf_tun_sends_ipv4_frame_to_ethernet_interface'
|
||||
hogs = []
|
||||
try:
|
||||
os.sched_setscheduler(0, os.SCHED_FIFO, os.sched_param(50))
|
||||
for cpu in sorted(os.sched_getaffinity(0)):
|
||||
hogs.append(subprocess.Popen([str(root / 'diagnostics/cpu-pressure'), str(cpu)]))
|
||||
failures = 0
|
||||
timeouts = 0
|
||||
for i in range(1, 51):
|
||||
assert all(proc.poll() is None for proc in hogs), 'CPU load ended early'
|
||||
result = subprocess.run(['chrt', '-f', '20', binary, case, '--exact', '--nocapture'], env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=10)
|
||||
(root / f'diagnostics/pressure-fixed-{i}.log').write_text(result.stdout)
|
||||
failures += bool(result.returncode)
|
||||
timeouts += 'timed out waiting for Ethernet frame' in result.stdout
|
||||
print('PRESSURE fixed', i, result.returncode, result.stdout, flush=True)
|
||||
assert 'tx_dropped=0\n' in result.stdout
|
||||
assert all(proc.poll() is None for proc in hogs), 'CPU load ended early'
|
||||
print('PRESSURE TOTAL fixed', 'failures', failures, 'timeouts', timeouts, flush=True)
|
||||
assert failures == 0
|
||||
finally:
|
||||
for proc in hogs:
|
||||
proc.kill()
|
||||
for proc in hogs:
|
||||
proc.wait()
|
||||
os.sched_setscheduler(0, os.SCHED_OTHER, os.sched_param(0))
|
||||
PY
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: bpf-diagnostics
|
||||
path: diagnostics
|
||||
@@ -94,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
|
||||
|
||||
@@ -138,7 +138,8 @@ jobs:
|
||||
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)
|
||||
|
||||
Generated
+31
-30
@@ -216,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]]
|
||||
@@ -227,7 +227,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2365,7 +2365,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users 0.5.3",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2685,6 +2685,7 @@ dependencies = [
|
||||
"ring",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
@@ -2701,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",
|
||||
@@ -3219,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]]
|
||||
@@ -5507,7 +5508,7 @@ dependencies = [
|
||||
"png 0.18.1",
|
||||
"serde",
|
||||
"thiserror 2.0.20",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5909,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]]
|
||||
@@ -6476,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]]
|
||||
@@ -7466,7 +7467,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8058,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",
|
||||
@@ -8116,7 +8117,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8127,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",
|
||||
@@ -8969,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]]
|
||||
@@ -9901,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]]
|
||||
@@ -9923,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]]
|
||||
@@ -10189,7 +10190,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"webpki-roots 1.0.9",
|
||||
"webpki-roots 1.0.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10607,7 +10608,7 @@ dependencies = [
|
||||
"png 0.18.1",
|
||||
"serde",
|
||||
"thiserror 2.0.20",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10647,7 +10648,7 @@ checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
|
||||
[[package]]
|
||||
name = "tun-easytier"
|
||||
version = "1.1.1"
|
||||
source = "git+https://github.com/EasyTier/rust-tun?rev=5ba6be073e5b486b68c12f80fac5cfb50601f024#5ba6be073e5b486b68c12f80fac5cfb50601f024"
|
||||
source = "git+https://github.com/EasyTier/rust-tun?rev=ac0c674c900dbdcf0b3fe9f5f6134c11579d1f7c#ac0c674c900dbdcf0b3fe9f5f6134c11579d1f7c"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg-if",
|
||||
@@ -10714,7 +10715,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11221,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",
|
||||
]
|
||||
@@ -11234,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",
|
||||
]
|
||||
@@ -11360,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]]
|
||||
@@ -11985,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]]
|
||||
|
||||
@@ -364,13 +364,18 @@ A core instance:
|
||||
`CoreProcessRuntime`, plus optional protocol and platform capabilities. The
|
||||
bundle carries capabilities, not preconstructed portable managers.
|
||||
|
||||
The standard native factory owns one continuously driven Tokio executor per
|
||||
instance. `multi_thread` selects a multi-thread or current-thread runtime;
|
||||
`multi_thread_count` sets the worker count with the historical minimum of two.
|
||||
Construction, start/stop, and FFI data-plane tasks use that executor, independent
|
||||
of the process management runtime. Dropping its Host owner shuts down the
|
||||
executor without blocking a worker on its own termination. Compact and WASI
|
||||
Hosts retain their externally supplied execution model.
|
||||
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
|
||||
@@ -552,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,6 +55,7 @@ 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
|
||||
@@ -109,7 +110,10 @@ 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",
|
||||
@@ -121,6 +125,8 @@ endpoint-discovery = [
|
||||
"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 = []
|
||||
@@ -140,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",
|
||||
|
||||
@@ -87,6 +87,7 @@ pub(crate) fn set_network_flags(result: &mut NetworkConfig, flags: FlagsPatch) {
|
||||
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;
|
||||
@@ -513,6 +514,7 @@ impl NetworkConfigExt for NetworkConfig {
|
||||
}
|
||||
}),
|
||||
accept_dns: self.enable_magic_dns,
|
||||
tld_dns_zone: self.tld_dns_zone.clone(),
|
||||
mtu: self
|
||||
.mtu
|
||||
.map(u32::try_from)
|
||||
@@ -929,6 +931,21 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[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 {
|
||||
@@ -1045,6 +1062,22 @@ 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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,9 +186,6 @@ where
|
||||
|
||||
if request.url.scheme() == "https" {
|
||||
let server_name = tls_server_name(&request.url)?;
|
||||
let root_store = rustls::RootCertStore {
|
||||
roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(),
|
||||
};
|
||||
// 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).
|
||||
@@ -194,9 +193,16 @@ where
|
||||
rustls::crypto::ring::default_provider(),
|
||||
))
|
||||
.with_safe_default_protocol_versions()
|
||||
.context("selecting HTTPS protocol versions failed")?
|
||||
.with_root_certificates(root_store)
|
||||
.with_no_client_auth();
|
||||
.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 = tls_config.with_no_client_auth();
|
||||
let stream = TlsConnector::from(Arc::new(tls_config))
|
||||
.connect(server_name, socket)
|
||||
.await
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -176,7 +176,19 @@ 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
|
||||
@@ -688,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 },
|
||||
@@ -710,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,
|
||||
@@ -748,7 +760,7 @@ where
|
||||
)))
|
||||
.await?;
|
||||
Ok(DataPlaneTcpStream::new(
|
||||
stream,
|
||||
DataPlaneTcpIo::External(stream),
|
||||
source_addr,
|
||||
None,
|
||||
DataPlaneTcpStreamRoute::External,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -542,9 +542,29 @@ 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;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use tokio::{
|
||||
sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel},
|
||||
task::JoinSet,
|
||||
};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::{
|
||||
packet::ZCPacket,
|
||||
@@ -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
|
||||
}
|
||||
@@ -259,25 +266,59 @@ impl<R: IcmpProxyRuntime + 'static> PeerPacketFilter for IcmpProxyServiceFilter<
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -133,6 +133,8 @@ pub struct TestHost {
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -179,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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -765,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,14 +82,23 @@ where
|
||||
}
|
||||
|
||||
#[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(())
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
@@ -531,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): (
|
||||
@@ -542,6 +543,7 @@ where
|
||||
Arc::new(ForeignDirectConnectorRpcRegistrar::new(
|
||||
adapters.host.clone(),
|
||||
stun.clone(),
|
||||
running_listeners.clone(),
|
||||
)),
|
||||
),
|
||||
None => (Arc::new(()), Arc::new(())),
|
||||
@@ -633,7 +635,6 @@ where
|
||||
accepted_tunnel_handler.clone(),
|
||||
)),
|
||||
};
|
||||
let running_listeners = Arc::new(RunningListenerRegistry::default());
|
||||
let PreparedListenerPlan {
|
||||
transports,
|
||||
external,
|
||||
@@ -987,6 +988,12 @@ where
|
||||
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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3487,6 +3487,353 @@ virtual_ip = "10.82.0.2/24"
|
||||
instance.stop().await;
|
||||
}
|
||||
|
||||
#[cfg(feature = "proxy-packet")]
|
||||
mod dynamic_packet_proxy {
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
use smoltcp::wire::{Icmpv4Message, Icmpv4Packet, IpProtocol, Ipv4Packet};
|
||||
|
||||
use super::*;
|
||||
use crate::{
|
||||
gateway::proxy::icmp_host::{IcmpProxyHost, IcmpProxySocket, ProxyRuntimeError},
|
||||
packet::{PacketType, ZCPacket},
|
||||
};
|
||||
|
||||
struct RecordingIcmpHost {
|
||||
opens: AtomicUsize,
|
||||
requests: tokio::sync::mpsc::UnboundedSender<Ipv4Addr>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl IcmpProxyHost for RecordingIcmpHost {
|
||||
async fn open_icmp_v4(
|
||||
&self,
|
||||
_context: SocketContext,
|
||||
) -> Result<Arc<dyn IcmpProxySocket>, ProxyRuntimeError> {
|
||||
self.opens.fetch_add(1, Ordering::Relaxed);
|
||||
Ok(Arc::new(self.requests.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl IcmpProxySocket for tokio::sync::mpsc::UnboundedSender<Ipv4Addr> {
|
||||
async fn send(
|
||||
&self,
|
||||
destination: Ipv4Addr,
|
||||
_packet: &[u8],
|
||||
) -> Result<(), ProxyRuntimeError> {
|
||||
self.send(destination).unwrap();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn recv(&self) -> Result<(IpAddr, Vec<u8>), ProxyRuntimeError> {
|
||||
std::future::pending().await
|
||||
}
|
||||
}
|
||||
|
||||
fn echo_request(source_peer: u32, destination_peer: u32) -> ZCPacket {
|
||||
let mut bytes = [0_u8; 28];
|
||||
let mut icmp = Icmpv4Packet::new_unchecked(&mut bytes[20..]);
|
||||
icmp.set_msg_type(Icmpv4Message::EchoRequest);
|
||||
icmp.set_echo_ident(7);
|
||||
icmp.set_echo_seq_no(1);
|
||||
icmp.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("198.51.100.42".parse().unwrap());
|
||||
ipv4.fill_checksum();
|
||||
let mut packet = ZCPacket::new_with_payload(&bytes);
|
||||
packet.fill_peer_manager_hdr(source_peer, destination_peer, PacketType::Data as u8);
|
||||
packet
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn first_dynamic_proxy_network_starts_forwarding_and_reuses_resources() {
|
||||
let mut config = test_config("dynamic-packet-proxy");
|
||||
config.peer.snapshot.runtime.core.routes.ipv4 = Some(IpPrefix {
|
||||
address: "10.0.0.1".parse().unwrap(),
|
||||
prefix_len: 24,
|
||||
});
|
||||
let mut updated = runtime_snapshot(&config);
|
||||
let host = Arc::new(TestHost::default());
|
||||
let (packet_sink, mut packets) = tokio::sync::mpsc::channel(16);
|
||||
let (requests, mut destinations) = tokio::sync::mpsc::unbounded_channel();
|
||||
let icmp_host = Arc::new(RecordingIcmpHost {
|
||||
opens: AtomicUsize::new(0),
|
||||
requests,
|
||||
});
|
||||
let mut adapters = adapters_with_host(host.clone(), None, Arc::new(packet_sink));
|
||||
adapters.icmp_proxy_host = Some(icmp_host.clone());
|
||||
let instance = CoreInstance::new(config.clone(), adapters).unwrap();
|
||||
config
|
||||
.peer
|
||||
.snapshot
|
||||
.runtime
|
||||
.core
|
||||
.routes
|
||||
.ipv4
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.address = "10.0.0.2".parse().unwrap();
|
||||
let source = build_instance(config).unwrap();
|
||||
instance.start().await.unwrap();
|
||||
source.start().await.unwrap();
|
||||
assert_eq!(host.tcp_binds.load(Ordering::Relaxed), 0);
|
||||
assert_eq!(icmp_host.opens.load(Ordering::Relaxed), 0);
|
||||
|
||||
let (client, server) = crate::tunnel::ring::create_ring_tunnel_pair();
|
||||
let (client_result, server_result) = tokio::join!(
|
||||
source.peer_manager.add_client_tunnel(client),
|
||||
instance.peer_manager.add_tunnel_as_server(server),
|
||||
);
|
||||
client_result.unwrap();
|
||||
server_result.unwrap();
|
||||
|
||||
let send = || async {
|
||||
source
|
||||
.peer_manager
|
||||
.send_msg_for_proxy(
|
||||
echo_request(source.peer_id(), instance.peer_id()),
|
||||
instance.peer_id(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
};
|
||||
send().await;
|
||||
tokio::time::timeout(Duration::from_secs(5), packets.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(icmp_host.opens.load(Ordering::Relaxed), 0);
|
||||
|
||||
Arc::make_mut(&mut updated.peer)
|
||||
.runtime
|
||||
.core
|
||||
.routes
|
||||
.proxy_networks = vec![proxy_network("192.0.2.0/24", Some("198.51.100.0/24"))];
|
||||
instance
|
||||
.update_runtime_config(updated.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(host.tcp_binds.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(icmp_host.opens.load(Ordering::Relaxed), 0);
|
||||
send().await;
|
||||
assert_eq!(
|
||||
tokio::time::timeout(Duration::from_secs(5), destinations.recv())
|
||||
.await
|
||||
.unwrap(),
|
||||
Some("192.0.2.42".parse().unwrap()),
|
||||
);
|
||||
|
||||
let mut removed = updated.clone();
|
||||
Arc::make_mut(&mut removed.peer)
|
||||
.runtime
|
||||
.core
|
||||
.routes
|
||||
.proxy_networks
|
||||
.clear();
|
||||
instance.update_runtime_config(removed).await.unwrap();
|
||||
send().await;
|
||||
tokio::time::timeout(Duration::from_secs(5), packets.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(destinations.try_recv().is_err());
|
||||
|
||||
instance.update_runtime_config(updated).await.unwrap();
|
||||
send().await;
|
||||
assert_eq!(
|
||||
tokio::time::timeout(Duration::from_secs(5), destinations.recv())
|
||||
.await
|
||||
.unwrap(),
|
||||
Some("192.0.2.42".parse().unwrap()),
|
||||
);
|
||||
assert_eq!(host.tcp_binds.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(icmp_host.opens.load(Ordering::Relaxed), 1);
|
||||
source.stop().await;
|
||||
instance.stop().await;
|
||||
assert_eq!(host.active_tcp_listeners.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dynamic_proxy_start_respects_instance_state_and_system_forwarding() {
|
||||
for forward_by_system in [false, true] {
|
||||
let mut config = test_config("dynamic-proxy-start-policy");
|
||||
config.connectivity.runtime.proxy.forward_by_system = forward_by_system;
|
||||
let mut updated = runtime_snapshot(&config);
|
||||
let host = Arc::new(TestHost::default());
|
||||
let (packet_sink, _packets) = tokio::sync::mpsc::channel(16);
|
||||
let instance = CoreInstance::new(
|
||||
config,
|
||||
adapters_with_host(host.clone(), None, Arc::new(packet_sink)),
|
||||
)
|
||||
.unwrap();
|
||||
Arc::make_mut(&mut updated.peer)
|
||||
.runtime
|
||||
.core
|
||||
.routes
|
||||
.proxy_networks = vec![proxy_network("192.0.2.0/24", None)];
|
||||
instance
|
||||
.update_runtime_config(updated.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(host.tcp_binds.load(Ordering::Relaxed), 0);
|
||||
instance.start().await.unwrap();
|
||||
instance.update_runtime_config(updated).await.unwrap();
|
||||
assert_eq!(
|
||||
host.tcp_binds.load(Ordering::Relaxed),
|
||||
usize::from(!forward_by_system),
|
||||
);
|
||||
instance.stop().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dynamic_proxy_start_reports_bind_failure_and_allows_retry() {
|
||||
let config = test_config("dynamic-proxy-start-failure");
|
||||
let mut updated = runtime_snapshot(&config);
|
||||
let host = Arc::new(TestHost::default());
|
||||
*host.reject_tcp_bind.lock().unwrap() = Some("0.0.0.0:20000".parse().unwrap());
|
||||
let (packet_sink, _packets) = tokio::sync::mpsc::channel(16);
|
||||
let instance = CoreInstance::new(
|
||||
config,
|
||||
adapters_with_host(host.clone(), None, Arc::new(packet_sink)),
|
||||
)
|
||||
.unwrap();
|
||||
instance.start().await.unwrap();
|
||||
Arc::make_mut(&mut updated.peer)
|
||||
.runtime
|
||||
.core
|
||||
.routes
|
||||
.proxy_networks = vec![proxy_network("192.0.2.0/24", None)];
|
||||
let error = instance
|
||||
.update_runtime_config(updated.clone())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("test host rejected TCP bind"));
|
||||
assert_eq!(host.active_tcp_listeners.load(Ordering::Relaxed), 0);
|
||||
// Configuration publication precedes resource startup. Retrying the
|
||||
// same update must activate the already published proxy network.
|
||||
assert_eq!(instance.node_snapshot().await.proxy_networks.len(), 1);
|
||||
*host.reject_tcp_bind.lock().unwrap() = None;
|
||||
instance.update_runtime_config(updated).await.unwrap();
|
||||
assert_eq!(host.active_tcp_listeners.load(Ordering::Relaxed), 1);
|
||||
instance.stop().await;
|
||||
assert_eq!(host.active_tcp_listeners.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelled_dynamic_proxy_start_allows_retry() {
|
||||
let config = test_config("dynamic-proxy-start-cancelled");
|
||||
let mut updated = runtime_snapshot(&config);
|
||||
let gate = Arc::new(tokio::sync::Semaphore::new(0));
|
||||
let host = Arc::new(TestHost {
|
||||
tcp_bind_gate: Some(gate.clone()),
|
||||
..Default::default()
|
||||
});
|
||||
let (packet_sink, _packets) = tokio::sync::mpsc::channel(16);
|
||||
let instance = CoreInstance::new(
|
||||
config,
|
||||
adapters_with_host(host.clone(), None, Arc::new(packet_sink)),
|
||||
)
|
||||
.unwrap();
|
||||
instance.start().await.unwrap();
|
||||
Arc::make_mut(&mut updated.peer)
|
||||
.runtime
|
||||
.core
|
||||
.routes
|
||||
.proxy_networks = vec![proxy_network("192.0.2.0/24", None)];
|
||||
let update = tokio::spawn({
|
||||
let instance = instance.clone();
|
||||
let updated = updated.clone();
|
||||
async move { instance.update_runtime_config(updated).await }
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while host.tcp_binds.load(Ordering::Relaxed) == 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
update.abort();
|
||||
assert!(update.await.unwrap_err().is_cancelled());
|
||||
assert_eq!(host.active_tcp_listeners.load(Ordering::Relaxed), 0);
|
||||
|
||||
gate.add_permits(1);
|
||||
instance.update_runtime_config(updated).await.unwrap();
|
||||
assert_eq!(host.tcp_binds.load(Ordering::Relaxed), 2);
|
||||
assert_eq!(host.active_tcp_listeners.load(Ordering::Relaxed), 1);
|
||||
instance.stop().await;
|
||||
assert_eq!(host.active_tcp_listeners.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_proxy_start_uses_instance_executor_after_caller_runtime_exits() {
|
||||
struct ExecutorRuntimeHost(tokio::runtime::Handle);
|
||||
|
||||
#[async_trait]
|
||||
impl InstanceRuntimeHost for ExecutorRuntimeHost {
|
||||
fn runtime_handle(&self) -> Option<tokio::runtime::Handle> {
|
||||
Some(self.0.clone())
|
||||
}
|
||||
|
||||
async fn prepare(
|
||||
&self,
|
||||
_packet_plane: Arc<CorePacketPlane>,
|
||||
) -> anyhow::Result<Option<Arc<dyn DhcpHost>>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn shutdown(&self) {}
|
||||
}
|
||||
|
||||
let executor = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let caller = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let host = Arc::new(TestHost::default());
|
||||
let config = test_config("dynamic-proxy-executor");
|
||||
let mut updated = runtime_snapshot(&config);
|
||||
let instance = {
|
||||
let _runtime = executor.enter();
|
||||
let (packet_sink, _packets) = tokio::sync::mpsc::channel(16);
|
||||
let mut adapters = adapters_with_host(host.clone(), None, Arc::new(packet_sink));
|
||||
adapters.instance_runtime =
|
||||
Arc::new(ExecutorRuntimeHost(executor.handle().clone()));
|
||||
CoreInstance::new(config, adapters).unwrap()
|
||||
};
|
||||
caller.block_on(async {
|
||||
instance.start().await.unwrap();
|
||||
assert_eq!(host.tcp_binds.load(Ordering::Relaxed), 0);
|
||||
Arc::make_mut(&mut updated.peer)
|
||||
.runtime
|
||||
.core
|
||||
.routes
|
||||
.proxy_networks = vec![proxy_network("192.0.2.0/24", None)];
|
||||
instance.update_runtime_config(updated).await.unwrap();
|
||||
});
|
||||
drop(caller);
|
||||
|
||||
assert_eq!(
|
||||
*host.tcp_bind_runtime.lock().unwrap(),
|
||||
Some(executor.handle().id()),
|
||||
);
|
||||
assert_eq!(host.active_tcp_listeners.load(Ordering::Relaxed), 1);
|
||||
executor.block_on(instance.stop());
|
||||
assert_eq!(host.active_tcp_listeners.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "dhcp-ipv4")]
|
||||
#[tokio::test]
|
||||
async fn runtime_core_accepts_explicit_dhcp_runtime_snapshot() {
|
||||
@@ -3747,6 +4094,52 @@ virtual_ip = "10.82.0.2/24"
|
||||
assert_eq!(state.drop_calls.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn foreign_direct_rpc_tracks_running_listeners() {
|
||||
use crate::{
|
||||
connectivity::direct::DirectConnectorRpcHandler,
|
||||
proto::{
|
||||
peer_rpc::{DirectConnectorRpc, GetIpListRequest},
|
||||
rpc_types::controller::BaseController,
|
||||
},
|
||||
};
|
||||
|
||||
let mut config = test_config("foreign-listeners");
|
||||
config.connectivity.listeners = Some(ListenerRuntimeConfig::new(
|
||||
vec!["tcp://0.0.0.0:0".parse().unwrap()],
|
||||
false,
|
||||
SocketContext::default(),
|
||||
));
|
||||
let instance = build_instance(config).unwrap();
|
||||
let handler = DirectConnectorRpcHandler::new_for_foreign_network_with_stun(
|
||||
Arc::new(TestHost::default()),
|
||||
instance.running_listeners.clone(),
|
||||
SocketContext::default(),
|
||||
None,
|
||||
);
|
||||
let advertised_listeners = || async {
|
||||
handler
|
||||
.get_ip_list(BaseController::default(), GetIpListRequest {})
|
||||
.await
|
||||
.unwrap()
|
||||
.listeners
|
||||
.into_iter()
|
||||
.map(Url::from)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
assert!(advertised_listeners().await.is_empty());
|
||||
instance.start().await.unwrap();
|
||||
assert!(
|
||||
instance
|
||||
.running_listeners()
|
||||
.contains(&"tcp://0.0.0.0:20000".parse().unwrap())
|
||||
);
|
||||
assert_eq!(advertised_listeners().await, instance.running_listeners());
|
||||
instance.stop().await;
|
||||
assert!(advertised_listeners().await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_listener_uses_core_running_listener_registry() {
|
||||
let external_url: Url = "unix:///tmp/easytier-external-listener-test"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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]])),
|
||||
))
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
use std::sync::Arc;
|
||||
use std::{
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use arc_swap::ArcSwapOption;
|
||||
use crossbeam_utils::atomic::AtomicCell;
|
||||
@@ -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},
|
||||
@@ -18,7 +27,7 @@ use crate::peers::{
|
||||
use crate::{
|
||||
config::PeerId,
|
||||
foundation::small_map_shard_amount,
|
||||
packet::ZCPacket,
|
||||
packet::{FlowKey, ZCPacket},
|
||||
peers::error::Error,
|
||||
proto::{core_peer::peer::PeerConnInfo, peer_rpc::PeerIdentityType},
|
||||
};
|
||||
@@ -27,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 {
|
||||
@@ -46,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)]
|
||||
@@ -67,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();
|
||||
@@ -131,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);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -152,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,
|
||||
@@ -213,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() {
|
||||
@@ -237,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(());
|
||||
}
|
||||
@@ -339,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(®ular) < conn_selection_key(&hole_punched));
|
||||
hole_punched.record_latency_for_test(1_000);
|
||||
assert!(conn_selection_key(&hole_punched) < conn_selection_key(®ular));
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -453,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;
|
||||
};
|
||||
|
||||
@@ -471,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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1216,6 +1216,23 @@ impl ForeignNetworkManager {
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether the peer map of `network_name` can route a packet back to
|
||||
/// `peer_id`, using the same gateway lookup the packet router performs.
|
||||
#[doc(hidden)]
|
||||
pub async fn has_gateway_for_peer(&self, network_name: &str, peer_id: PeerId) -> Option<bool> {
|
||||
let item = self
|
||||
.data
|
||||
.network_peer_maps
|
||||
.get(network_name)
|
||||
.map(|v| v.clone())?;
|
||||
Some(
|
||||
item.peer_map
|
||||
.get_gateway_peer_id(peer_id, NextHopPolicy::LeastHop)
|
||||
.await
|
||||
.is_some(),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn list_foreign_network_infos(
|
||||
&self,
|
||||
include_trusted_keys: bool,
|
||||
|
||||
@@ -1311,6 +1311,18 @@ impl PeerManagerCore {
|
||||
self.get_route().list_foreign_network_info().await
|
||||
}
|
||||
|
||||
/// Whether the foreign network peer map can route back to `peer_id`.
|
||||
#[doc(hidden)]
|
||||
pub async fn foreign_network_has_gateway(
|
||||
&self,
|
||||
network_name: &str,
|
||||
peer_id: PeerId,
|
||||
) -> Option<bool> {
|
||||
self.foreign_network_manager
|
||||
.has_gateway_for_peer(network_name, peer_id)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_foreign_network_infos(
|
||||
&self,
|
||||
include_trusted_keys: bool,
|
||||
@@ -1328,6 +1340,22 @@ impl PeerManagerCore {
|
||||
self.acl_filter.get_stats()
|
||||
}
|
||||
|
||||
/// Bytes charged by the foreign network forward limiter, if configured.
|
||||
#[doc(hidden)]
|
||||
pub fn foreign_forward_metered_bytes(&self, network_name: &str) -> Option<u64> {
|
||||
self.context
|
||||
.foreign_forward_limiter(network_name)
|
||||
.map(|limiter| limiter.metered_bytes())
|
||||
}
|
||||
|
||||
/// Bytes charged by the instance-wide receive limiter, if configured.
|
||||
#[doc(hidden)]
|
||||
pub fn instance_recv_metered_bytes(&self) -> Option<u64> {
|
||||
self.context
|
||||
.recv_limiter("", false)
|
||||
.map(|limiter| limiter.metered_bytes())
|
||||
}
|
||||
|
||||
pub fn acl_filter(&self) -> Arc<AclFilter> {
|
||||
self.acl_filter.clone()
|
||||
}
|
||||
@@ -1769,6 +1797,13 @@ impl PeerManagerCore {
|
||||
*self.exit_nodes.write().await = exit_nodes;
|
||||
}
|
||||
|
||||
/// Shares the network's current exit nodes, including later updates, while
|
||||
/// keeping this peer's route lookup and packet identity independent.
|
||||
pub(super) fn inherit_exit_nodes(&mut self, network: &Self) {
|
||||
self.exit_nodes = network.exit_nodes.clone();
|
||||
self.outbound_packet_router.exit_nodes = self.exit_nodes.clone();
|
||||
}
|
||||
|
||||
fn reload_acl(&self, acl: Option<&crate::proto::acl::Acl>) {
|
||||
self.acl_filter.reload_rules(acl);
|
||||
}
|
||||
@@ -2624,6 +2659,10 @@ impl PeerOutboundPacketRouter {
|
||||
self.mark_recent_traffic_with_policy(dst_peer_id, packet_policy);
|
||||
self.check_p2p_only_before_send(dst_peer_id, packet_policy)?;
|
||||
|
||||
if msg.peer_manager_header().unwrap().packet_type == PacketType::Data as u8 {
|
||||
msg.flow_key = crate::packet::flow::parse_flow_key(&msg);
|
||||
}
|
||||
|
||||
self.counters
|
||||
.compress_tx_bytes_before
|
||||
.add(msg.buf_len() as u64);
|
||||
@@ -2770,7 +2809,7 @@ impl PeerOutboundPacketRouter {
|
||||
pub async fn send_msg_by_ip(
|
||||
&self,
|
||||
mut msg: ZCPacket,
|
||||
ip_addr: IpAddr,
|
||||
mut ip_addr: IpAddr,
|
||||
not_send_to_self: bool,
|
||||
) -> Result<(), Error> {
|
||||
tracing::trace!(
|
||||
@@ -2780,9 +2819,25 @@ impl PeerOutboundPacketRouter {
|
||||
);
|
||||
|
||||
msg.fill_peer_manager_hdr(self.my_peer_id, 0, PacketType::Data as u8);
|
||||
let filters_registered = !self.nic_packet_process_pipeline.load().is_empty();
|
||||
if !self.run_nic_packet_process_pipeline(&mut msg).await {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if filters_registered
|
||||
&& let Ok(meta) = crate::instance::packet_io::parse_ip_packet(msg.payload())
|
||||
&& meta.destination != ip_addr
|
||||
{
|
||||
tracing::trace!(
|
||||
original_dst = %ip_addr,
|
||||
new_dst = %meta.destination,
|
||||
"destination rewritten by nic packet filter"
|
||||
);
|
||||
ip_addr = meta.destination;
|
||||
}
|
||||
|
||||
msg.flow_key = crate::packet::flow::parse_flow_key(&msg);
|
||||
|
||||
let packet_policy = self.context.packet_policy();
|
||||
let cur_to_peer_id = msg.peer_manager_header().unwrap().to_peer_id.into();
|
||||
if cur_to_peer_id != 0 {
|
||||
@@ -3877,6 +3932,247 @@ mod tests {
|
||||
assert!(!is_self);
|
||||
}
|
||||
|
||||
#[cfg(feature = "aes-gcm")]
|
||||
#[tokio::test]
|
||||
async fn proxy_ip_responses_get_flow_keys_without_classifying_fragments_or_wrapped_data() {
|
||||
struct CaptureProxyPackets(mpsc::UnboundedSender<ZCPacket>);
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl PeerPacketFilter for CaptureProxyPackets {
|
||||
async fn try_process_packet_from_peer(
|
||||
&self,
|
||||
packet: ZCPacket,
|
||||
) -> PeerPacketFilterResult {
|
||||
let packet_type = packet.peer_manager_header().unwrap().packet_type;
|
||||
if [
|
||||
PacketType::Data,
|
||||
PacketType::KcpSrc,
|
||||
PacketType::KcpDst,
|
||||
PacketType::QuicSrc,
|
||||
PacketType::QuicDst,
|
||||
]
|
||||
.iter()
|
||||
.any(|kind| *kind as u8 == packet_type)
|
||||
{
|
||||
self.0.send(packet).unwrap();
|
||||
PeerPacketFilterResult::Consumed
|
||||
} else {
|
||||
PeerPacketFilterResult::Pass(packet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut config = PortablePeerManagerConfig::new(portable_runtime_config("proxy-flow-key"));
|
||||
config.snapshot.flags.enable_encryption = true;
|
||||
config.snapshot.flags.encryption_algorithm = "aes-gcm".to_owned();
|
||||
let core = build_portable_config_for_test(config).unwrap();
|
||||
core.run().await.unwrap();
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
core.add_packet_process_pipeline(Box::new(CaptureProxyPackets(tx)))
|
||||
.await;
|
||||
|
||||
let expected = crate::packet::FlowKey {
|
||||
src: ([10, 0, 0, 1], 1234).into(),
|
||||
dst: ([10, 0, 0, 2], 5000).into(),
|
||||
protocol: 17,
|
||||
};
|
||||
for (packet_type, fragment_flags, key) in [
|
||||
(PacketType::Data, 0u16, Some(expected)),
|
||||
(PacketType::Data, 0x2000, None),
|
||||
(PacketType::Data, 1, None),
|
||||
(PacketType::KcpSrc, 0, None),
|
||||
(PacketType::KcpDst, 0, None),
|
||||
(PacketType::QuicSrc, 0, None),
|
||||
(PacketType::QuicDst, 0, None),
|
||||
] {
|
||||
// Wrapped payloads deliberately resemble IP to check the packet-type guard.
|
||||
let mut bytes = vec![0u8; 28];
|
||||
bytes[0] = 0x45;
|
||||
bytes[2..4].copy_from_slice(&28u16.to_be_bytes());
|
||||
bytes[6..8].copy_from_slice(&fragment_flags.to_be_bytes());
|
||||
bytes[8] = 64;
|
||||
bytes[9] = 17;
|
||||
bytes[12..16].copy_from_slice(&[10, 0, 0, 1]);
|
||||
bytes[20..22].copy_from_slice(&1234u16.to_be_bytes());
|
||||
bytes[22..24].copy_from_slice(&5000u16.to_be_bytes());
|
||||
bytes[24..26].copy_from_slice(&8u16.to_be_bytes());
|
||||
set_packet_destination(&mut bytes, expected.dst.ip());
|
||||
let mut packet = ZCPacket::new_with_payload(&bytes);
|
||||
packet.fill_peer_manager_hdr(core.my_peer_id(), core.my_peer_id(), packet_type as u8);
|
||||
packet.mut_peer_manager_header().unwrap().set_no_proxy(true);
|
||||
assert!(packet.flow_key.is_none());
|
||||
core.send_msg_for_proxy(packet, core.my_peer_id())
|
||||
.await
|
||||
.unwrap();
|
||||
let received = tokio::time::timeout(Duration::from_secs(1), rx.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(received.flow_key, key, "{packet_type:?}/{fragment_flags}");
|
||||
assert_eq!(received.payload(), bytes);
|
||||
}
|
||||
core.clear_resources().await;
|
||||
}
|
||||
|
||||
fn set_packet_destination(packet: &mut [u8], destination: IpAddr) {
|
||||
match destination {
|
||||
IpAddr::V4(destination) => {
|
||||
assert_eq!(packet[0] >> 4, 4);
|
||||
packet[16..20].copy_from_slice(&destination.octets());
|
||||
packet[10..12].fill(0);
|
||||
let mut checksum: u32 = packet[..20]
|
||||
.chunks_exact(2)
|
||||
.map(|word| u32::from(u16::from_be_bytes([word[0], word[1]])))
|
||||
.sum();
|
||||
while checksum >> 16 != 0 {
|
||||
checksum = (checksum & 0xffff) + (checksum >> 16);
|
||||
}
|
||||
packet[10..12].copy_from_slice(&(!(checksum as u16)).to_be_bytes());
|
||||
}
|
||||
IpAddr::V6(destination) => {
|
||||
assert_eq!(packet[0] >> 4, 6);
|
||||
packet[24..40].copy_from_slice(&destination.octets());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DestinationRewriteNicFilter(IpAddr);
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl super::super::NicPacketFilter for DestinationRewriteNicFilter {
|
||||
async fn try_process_packet_from_nic(&self, packet: &mut ZCPacket) -> bool {
|
||||
// Leave to_peer_id unset: the router must use the rewritten IP header.
|
||||
assert_eq!(packet.peer_manager_header().unwrap().to_peer_id.get(), 0);
|
||||
set_packet_destination(packet.mut_payload(), self.0);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_nic_filter_destination_routing(addresses: [IpAddr; 4]) {
|
||||
let [source, original, rewritten, unrouted] = addresses;
|
||||
let mut peers = Vec::new();
|
||||
let mut receivers = Vec::new();
|
||||
for address in [source, original, rewritten] {
|
||||
let mut runtime = portable_runtime_config("nic-filter-routing");
|
||||
if address.is_ipv4() {
|
||||
runtime.core.routes.ipv4 = Some(IpPrefix::new(address, 24).unwrap());
|
||||
} else {
|
||||
runtime.core.routes.ipv6 = Some(IpPrefix::new(address, 64).unwrap());
|
||||
}
|
||||
let (sender, receiver) = host_packet_channel();
|
||||
let peer = PeerManagerCore::new_portable_for_test(
|
||||
PortablePeerManagerConfig::new(runtime),
|
||||
sender,
|
||||
)
|
||||
.unwrap();
|
||||
peer.run().await.unwrap();
|
||||
peers.push(peer);
|
||||
receivers.push(receiver);
|
||||
}
|
||||
let sender = &peers[0];
|
||||
for peer in &peers[1..] {
|
||||
let (client, server) = crate::tunnel::ring::create_ring_tunnel_pair();
|
||||
let (client_result, server_result) = tokio::join!(
|
||||
sender.add_client_tunnel(client),
|
||||
peer.add_tunnel_as_server(server),
|
||||
);
|
||||
client_result.unwrap();
|
||||
server_result.unwrap();
|
||||
}
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
loop {
|
||||
if sender.get_msg_dst_peer(&original).await == (vec![peers[1].my_peer_id()], false)
|
||||
&& sender.get_msg_dst_peer(&rewritten).await
|
||||
== (vec![peers[2].my_peer_id()], false)
|
||||
{
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("both destination routes must be ready before sending");
|
||||
assert_eq!(
|
||||
sender.get_msg_dst_peer(&unrouted).await,
|
||||
(Vec::new(), false)
|
||||
);
|
||||
sender
|
||||
.add_nic_packet_process_pipeline(Box::new(DestinationRewriteNicFilter(rewritten)))
|
||||
.await;
|
||||
|
||||
// Exercise both misdelivery to an existing peer and silent loss for an
|
||||
// address with no route. Send once per case, without retrying data packets.
|
||||
for destination in [original, unrouted] {
|
||||
let mut payload = match source {
|
||||
IpAddr::V4(source) => {
|
||||
let mut payload = vec![0u8; 20];
|
||||
payload[0] = 0x45;
|
||||
payload[2..4].copy_from_slice(&20u16.to_be_bytes());
|
||||
payload[8] = 64;
|
||||
payload[9] = 253; // Experimental protocol; no transport header.
|
||||
payload[12..16].copy_from_slice(&source.octets());
|
||||
payload
|
||||
}
|
||||
IpAddr::V6(source) => {
|
||||
let mut payload = vec![0u8; 40];
|
||||
payload[0] = 0x60;
|
||||
payload[6] = 59; // No next header.
|
||||
payload[7] = 64;
|
||||
payload[8..24].copy_from_slice(&source.octets());
|
||||
payload
|
||||
}
|
||||
};
|
||||
set_packet_destination(&mut payload, destination);
|
||||
sender
|
||||
.send_msg_by_ip(ZCPacket::new_with_payload(&payload), destination, false)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let packet = tokio::time::timeout(Duration::from_secs(5), receivers[2].recv())
|
||||
.await
|
||||
.expect("rewritten destination peer did not receive the packet")
|
||||
.unwrap()
|
||||
.into_core_packet();
|
||||
set_packet_destination(&mut payload, rewritten);
|
||||
assert_eq!(packet.payload(), payload);
|
||||
let header = packet.peer_manager_header().unwrap();
|
||||
assert_eq!(header.from_peer_id.get(), sender.my_peer_id());
|
||||
assert_eq!(header.to_peer_id.get(), peers[2].my_peer_id());
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(100), receivers[1].recv())
|
||||
.await
|
||||
.is_err(),
|
||||
"packet reached the old destination"
|
||||
);
|
||||
}
|
||||
|
||||
for peer in &peers {
|
||||
peer.clear_resources().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nic_filter_rewritten_ipv4_destination_controls_routing() {
|
||||
check_nic_filter_destination_routing([
|
||||
"10.82.0.1".parse().unwrap(),
|
||||
"10.82.0.2".parse().unwrap(),
|
||||
"10.82.0.3".parse().unwrap(),
|
||||
"10.82.0.99".parse().unwrap(),
|
||||
])
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nic_filter_rewritten_ipv6_destination_controls_routing() {
|
||||
check_nic_filter_destination_routing([
|
||||
"fd00::1".parse().unwrap(),
|
||||
"fd00::2".parse().unwrap(),
|
||||
"fd00::3".parse().unwrap(),
|
||||
"fd00::99".parse().unwrap(),
|
||||
])
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn foreign_network_stop_waits_for_inflight_admission() {
|
||||
let core = build_portable_for_test(portable_runtime_config("portable-net")).unwrap();
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! egress, environment facts, and the WASI mechanism backend) live in
|
||||
//! [`crate::host`].
|
||||
|
||||
pub mod retry;
|
||||
pub mod ring;
|
||||
pub mod tcp;
|
||||
pub mod udp;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Retry runtime listener failures until the owning task is cancelled.
|
||||
|
||||
use super::SocketListener;
|
||||
|
||||
pub async fn wait() {
|
||||
crate::foundation::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
/// For owners whose listener can be restarted in place after an accept error.
|
||||
/// The first listen remains fallible so startup errors reach the caller.
|
||||
pub async fn accept_with_rebind<L: SocketListener + ?Sized>(listener: &mut L) -> L::Accepted {
|
||||
loop {
|
||||
match listener.accept().await {
|
||||
Ok(accepted) => return accepted,
|
||||
Err(error) => {
|
||||
tracing::warn!(target: "CORE", url = %listener.local_url(), error = %format_args!("{error:#}"), "listener failed; rebinding");
|
||||
}
|
||||
}
|
||||
loop {
|
||||
wait().await;
|
||||
match listener.listen().await {
|
||||
Ok(()) => break,
|
||||
Err(error) => {
|
||||
tracing::warn!(target: "CORE", url = %listener.local_url(), error = %format_args!("{error:#}"), "listener bind failed; retrying");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::{collections::VecDeque, io};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Listener {
|
||||
accepts: VecDeque<io::Result<usize>>,
|
||||
binds: VecDeque<io::Result<()>>,
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl SocketListener for Listener {
|
||||
type Accepted = usize;
|
||||
async fn listen(&mut self) -> anyhow::Result<()> {
|
||||
Ok(self.binds.pop_front().unwrap_or(Ok(()))?)
|
||||
}
|
||||
async fn accept(&mut self) -> anyhow::Result<usize> {
|
||||
Ok(self.accepts.pop_front().expect("unexpected retry")?)
|
||||
}
|
||||
fn local_url(&self) -> url::Url {
|
||||
"tcp://127.0.0.1:22020".parse().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn unknown_accept_and_repeated_bind_failures_do_not_stop_recovery() {
|
||||
let mut listener = Listener {
|
||||
accepts: VecDeque::from([Err(io::Error::other("unknown failure")), Ok(7)]),
|
||||
binds: (0..10)
|
||||
.map(|_| Err(io::ErrorKind::PermissionDenied.into()))
|
||||
.collect(),
|
||||
};
|
||||
let start = tokio::time::Instant::now();
|
||||
assert_eq!(accept_with_rebind(&mut listener).await, 7);
|
||||
assert_eq!(start.elapsed(), std::time::Duration::from_millis(1100));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn rebind_wait_is_cancelled_with_its_owner() {
|
||||
let mut listener = Listener {
|
||||
accepts: VecDeque::from([Err(io::ErrorKind::BrokenPipe.into()), Ok(7)]),
|
||||
binds: VecDeque::from([Err(io::ErrorKind::AddrInUse.into()), Ok(())]),
|
||||
};
|
||||
assert!(
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_millis(150),
|
||||
accept_with_rebind(&mut listener)
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(listener.binds.len(), 1);
|
||||
assert_eq!(listener.accepts.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -363,15 +363,14 @@ where
|
||||
type Accepted = AcceptedTcpSocket<F>;
|
||||
|
||||
async fn listen(&mut self) -> anyhow::Result<()> {
|
||||
if self.listener.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Calling listen again replaces the old binding, including after an error.
|
||||
self.listener = None;
|
||||
let listener = self.factory.bind_tcp(self.options.clone()).await?;
|
||||
let local_addr = listener.local_addr()?;
|
||||
self.url
|
||||
.set_port(Some(local_addr.port()))
|
||||
.map_err(|_| anyhow::anyhow!("failed to update tcp listener port for {}", self.url))?;
|
||||
self.options.bind.local_addr = Some(local_addr);
|
||||
self.listener = Some(listener);
|
||||
Ok(())
|
||||
}
|
||||
@@ -750,4 +749,25 @@ mod tests {
|
||||
|
||||
assert_eq!(accepted.peer_addr().unwrap(), peer_addr);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn tcp_socket_listener_releases_failed_binding_and_keeps_assigned_port() {
|
||||
let bound_addr: SocketAddr = "127.0.0.1:12010".parse().unwrap();
|
||||
let listener = Arc::new(MockTcpListener::new(
|
||||
bound_addr,
|
||||
vec![Err(io::ErrorKind::BrokenPipe.into())],
|
||||
));
|
||||
let factory = Arc::new(MockTcpListenerFactory::new(listener));
|
||||
let mut socket_listener = TcpSocketListener::new_with_options(
|
||||
"tcp://127.0.0.1:0".parse().unwrap(),
|
||||
TcpListenOptions::direct_connect("127.0.0.1:0".parse().unwrap()),
|
||||
factory.clone(),
|
||||
);
|
||||
socket_listener.listen().await.unwrap();
|
||||
assert!(socket_listener.accept().await.is_err());
|
||||
assert!(socket_listener.listener.is_some());
|
||||
socket_listener.listen().await.unwrap();
|
||||
let binds = factory.binds.lock().unwrap();
|
||||
assert_eq!(binds.len(), 2);
|
||||
assert_eq!(binds[1].bind.local_addr, Some(bound_addr));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,7 +295,15 @@ where
|
||||
let accept = self.classified_accept(protocol)?;
|
||||
accept.accept_enabled.store(true, Ordering::Relaxed);
|
||||
let mut accepted_rx = accept.accepted_rx.lock().await;
|
||||
accepted_rx.recv().await.ok_or_else(|| {
|
||||
let mut shutdown = self.session_shutdown_tx.subscribe();
|
||||
// The layer owns the classified senders, so recv() alone does not
|
||||
// finish when the socket receive task fails.
|
||||
let session = tokio::select! {
|
||||
biased;
|
||||
_ = shutdown.wait_for(|closed| *closed) => None,
|
||||
session = accepted_rx.recv() => session,
|
||||
};
|
||||
session.ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::UnexpectedEof,
|
||||
format!("{protocol:?} udp listener closed"),
|
||||
@@ -1175,7 +1183,7 @@ where
|
||||
self.factory.clone(),
|
||||
));
|
||||
let mut session = layer.open_classified_session(request.protocol, request.remote_addr)?;
|
||||
session._cleanup.layer_guard = Some(Box::new(layer));
|
||||
session.keep_layer_alive(layer);
|
||||
Ok(session)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,15 +116,16 @@ where
|
||||
type Accepted = UdpSession;
|
||||
|
||||
async fn listen(&mut self) -> anyhow::Result<()> {
|
||||
if self.layer.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
self.layer = None;
|
||||
self.socket = None;
|
||||
*self.layer_ref.lock().unwrap() = None;
|
||||
|
||||
let socket = self.factory.bind_udp(self.request.bind.clone()).await?;
|
||||
let local_addr = socket.local_addr()?;
|
||||
self.url
|
||||
.set_port(Some(local_addr.port()))
|
||||
.map_err(|_| anyhow::anyhow!("failed to update udp listener port for {}", self.url))?;
|
||||
self.request.bind.local_addr = Some(local_addr);
|
||||
|
||||
let layer = Arc::new(UdpSessionLayer::new_with_stun_responder(
|
||||
socket.clone(),
|
||||
|
||||
@@ -541,7 +541,7 @@ pub(crate) struct UdpSessionCleanup {
|
||||
session_close: Option<UdpSessionClose>,
|
||||
shutdown: Option<watch::Sender<bool>>,
|
||||
tasks: Vec<JoinHandle<()>>,
|
||||
pub(super) layer_guard: Option<Box<dyn Send + Sync>>,
|
||||
layer_guard: Arc<StdMutex<Option<Box<dyn Send + Sync>>>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for UdpSessionCleanup {
|
||||
@@ -550,7 +550,10 @@ impl std::fmt::Debug for UdpSessionCleanup {
|
||||
.field("has_session_close", &self.session_close.is_some())
|
||||
.field("has_shutdown", &self.shutdown.is_some())
|
||||
.field("tasks", &self.tasks.len())
|
||||
.field("has_layer_guard", &self.layer_guard.is_some())
|
||||
.field(
|
||||
"has_layer_guard",
|
||||
&self.layer_guard.lock().unwrap().is_some(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -594,10 +597,12 @@ impl UdpSession {
|
||||
rings.session_send_rx,
|
||||
close.clone(),
|
||||
));
|
||||
let layer_guard = Arc::new(StdMutex::new(None));
|
||||
let shutdown_task = tokio::spawn(close_udp_session_on_shutdown(
|
||||
shutdown,
|
||||
close.clone(),
|
||||
send_task.abort_handle(),
|
||||
layer_guard.clone(),
|
||||
));
|
||||
|
||||
Self {
|
||||
@@ -612,7 +617,7 @@ impl UdpSession {
|
||||
session_close: Some(close),
|
||||
shutdown: None,
|
||||
tasks: vec![send_task, shutdown_task],
|
||||
layer_guard: None,
|
||||
layer_guard,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -621,7 +626,12 @@ impl UdpSession {
|
||||
where
|
||||
T: Send + Sync + 'static,
|
||||
{
|
||||
self._cleanup.layer_guard = Some(Box::new(layer_guard));
|
||||
let mut guard = self._cleanup.layer_guard.lock().unwrap();
|
||||
// Accept may finish after shutdown. Synchronize with cleanup so a
|
||||
// closed session cannot retain a failed listener's socket.
|
||||
if !*self.closed.borrow() {
|
||||
*guard = Some(Box::new(layer_guard));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn into_tunnel_parts(self) -> UdpSessionTunnelParts {
|
||||
@@ -899,12 +909,26 @@ async fn close_udp_session_on_shutdown(
|
||||
mut shutdown: watch::Receiver<bool>,
|
||||
close: UdpSessionClose,
|
||||
send_task: tokio::task::AbortHandle,
|
||||
layer_guard: Arc<StdMutex<Option<Box<dyn Send + Sync>>>>,
|
||||
) {
|
||||
if !*shutdown.borrow() {
|
||||
let _ = shutdown.changed().await;
|
||||
let mut closed = close.close.subscribe();
|
||||
if !*closed.borrow() {
|
||||
if *shutdown.borrow() {
|
||||
close.close();
|
||||
} else {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = closed.changed() => {},
|
||||
_ = shutdown.changed() => {
|
||||
if !*closed.borrow() {
|
||||
close.close();
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
close.close();
|
||||
send_task.abort();
|
||||
layer_guard.lock().unwrap().take();
|
||||
}
|
||||
|
||||
pub(super) fn dispatch_payload_to_session(
|
||||
@@ -934,6 +958,31 @@ pub(super) fn dispatch_payload_to_session(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn closed_session_cleanup_preserves_replacement_with_same_key() {
|
||||
let sessions = Arc::new(DashMap::new());
|
||||
let key = UdpSessionKey::new("127.0.0.1:12001".parse().unwrap(), 1);
|
||||
let old = create_udp_session_rings();
|
||||
sessions.insert(key, udp_session_registry_entry(&old));
|
||||
let close = UdpSessionClose::easy_tier(key, old.close_tx.clone(), sessions.clone());
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
let send_task = tokio::spawn(std::future::pending::<()>());
|
||||
let mut cleanup = Box::pin(close_udp_session_on_shutdown(
|
||||
shutdown_rx,
|
||||
close.clone(),
|
||||
send_task.abort_handle(),
|
||||
Arc::new(StdMutex::new(None)),
|
||||
));
|
||||
assert!(futures::poll!(&mut cleanup).is_pending());
|
||||
close.close();
|
||||
let replacement = create_udp_session_rings();
|
||||
sessions.insert(key, udp_session_registry_entry(&replacement));
|
||||
shutdown_tx.send(true).unwrap();
|
||||
cleanup.await;
|
||||
assert!(sessions.contains_key(&key));
|
||||
assert!(!*replacement.close_rx.borrow());
|
||||
}
|
||||
|
||||
impl UdpSessionClose {
|
||||
fn signal_only(close: watch::Sender<bool>) -> Self {
|
||||
Self {
|
||||
|
||||
@@ -1459,12 +1459,16 @@ async fn udp_session_recv_loop_error_closes_registered_sessions() {
|
||||
let socket = Arc::new(MockVirtualUdpSocket::new(local_addr, Vec::new()));
|
||||
let sessions = Arc::new(DashMap::new());
|
||||
let (session_shutdown_tx, session_shutdown_rx) = watch::channel(false);
|
||||
let session = create_test_easy_tier_mux_session_with_shutdown(
|
||||
let mut session = create_test_easy_tier_mux_session_with_shutdown(
|
||||
socket.clone(),
|
||||
UdpSessionKey::new(peer_addr, conn_id),
|
||||
sessions.clone(),
|
||||
session_shutdown_rx,
|
||||
);
|
||||
let guard = Arc::new(());
|
||||
let weak_guard = Arc::downgrade(&guard);
|
||||
session.keep_layer_alive(guard);
|
||||
assert!(weak_guard.upgrade().is_some());
|
||||
let pending_connects = Arc::new(DashMap::new());
|
||||
let classified_sessions = Arc::new(DashMap::new());
|
||||
let classified_accepts = create_classified_udp_session_accepts();
|
||||
@@ -1507,6 +1511,18 @@ async fn udp_session_recv_loop_error_closes_registered_sessions() {
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(err.kind(), io::ErrorKind::UnexpectedEof);
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(1), async {
|
||||
while weak_guard.upgrade().is_some() {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let guard = Arc::new(());
|
||||
let weak_guard = Arc::downgrade(&guard);
|
||||
session.keep_layer_alive(guard);
|
||||
assert!(weak_guard.upgrade().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2088,12 +2104,15 @@ impl VirtualUdpSocketFactory for MockVirtualUdpSocketFactory {
|
||||
|
||||
async fn bind_udp(&self, options: UdpBindOptions) -> anyhow::Result<Arc<Self::Socket>> {
|
||||
self.bind_options.lock().unwrap().push(options.clone());
|
||||
let local_addr = options.local_addr.unwrap_or_else(|| {
|
||||
SocketAddr::from((
|
||||
[127, 0, 0, 1],
|
||||
self.next_port.fetch_add(1, Ordering::Relaxed),
|
||||
))
|
||||
});
|
||||
let local_addr = options
|
||||
.local_addr
|
||||
.filter(|addr| addr.port() != 0)
|
||||
.unwrap_or_else(|| {
|
||||
SocketAddr::from((
|
||||
[127, 0, 0, 1],
|
||||
self.next_port.fetch_add(1, Ordering::Relaxed),
|
||||
))
|
||||
});
|
||||
let incoming = self
|
||||
.incoming
|
||||
.lock()
|
||||
@@ -2148,6 +2167,59 @@ async fn udp_session_socket_listener_builds_port_bound_bind_options() {
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn classified_accept_observes_receive_failure_before_and_during_wait() {
|
||||
for protocol in [UdpSessionProtocol::WireGuard, UdpSessionProtocol::Quic] {
|
||||
for failed_before_accept in [false, true] {
|
||||
let socket = Arc::new(MockVirtualUdpSocket::new(
|
||||
"127.0.0.1:12000".parse().unwrap(),
|
||||
Vec::new(),
|
||||
));
|
||||
let layer = UdpSessionLayer::new(socket);
|
||||
if failed_before_accept {
|
||||
// The mux sender closes when the receive task has exited.
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_secs(1), layer.accept())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
let mut accept = Box::pin(layer.accept_classified_session(protocol));
|
||||
if !failed_before_accept {
|
||||
assert!(futures::poll!(&mut accept).is_pending());
|
||||
}
|
||||
let error = tokio::time::timeout(Duration::from_secs(1), accept)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind(), io::ErrorKind::UnexpectedEof);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn udp_listener_releases_failed_binding_and_keeps_assigned_port() {
|
||||
let factory = Arc::new(MockVirtualUdpSocketFactory::new(13000));
|
||||
let mut listener = UdpSessionSocketListener::new(
|
||||
"udp://127.0.0.1:0".parse().unwrap(),
|
||||
"127.0.0.1:0".parse().unwrap(),
|
||||
factory.clone(),
|
||||
);
|
||||
listener.listen().await.unwrap();
|
||||
let bound_addr = listener.bound_socket().unwrap().local_addr().unwrap();
|
||||
let old_socket = Arc::downgrade(&listener.bound_socket().unwrap());
|
||||
factory.sockets.lock().unwrap().clear();
|
||||
assert!(listener.accept().await.is_err());
|
||||
listener.listen().await.unwrap();
|
||||
assert!(old_socket.upgrade().is_none());
|
||||
assert_eq!(
|
||||
listener.bound_socket().unwrap().local_addr().unwrap(),
|
||||
bound_addr
|
||||
);
|
||||
assert_eq!(factory.bind_options().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn udp_session_socket_listener_accepts_easy_tier_mux_session() {
|
||||
let local_addr = SocketAddr::from(([127, 0, 0, 1], 11010));
|
||||
|
||||
@@ -1011,14 +1011,12 @@ mod tests {
|
||||
assert!(s.check_replay_for_test(1, 0, SecureDatagramDirection::AToB, now + 1));
|
||||
|
||||
s.sync_root_key(root_key, 2, 2, true);
|
||||
assert!(s.check_replay_for_test(2, 0, SecureDatagramDirection::AToB, now + 2));
|
||||
// Use the actual deadline so scheduling delays before sync do not affect the test.
|
||||
let expires_at_ms = s.sync_rx_grace_expires_at_ms.load(Ordering::Relaxed);
|
||||
assert!(s.check_replay_for_test(2, 0, SecureDatagramDirection::AToB, expires_at_ms - 1));
|
||||
assert!(s.check_replay_for_test(0, 1, SecureDatagramDirection::AToB, expires_at_ms - 1));
|
||||
|
||||
assert!(!s.check_replay_for_test(
|
||||
0,
|
||||
1,
|
||||
SecureDatagramDirection::AToB,
|
||||
now + SecureDatagramSession::SYNC_RX_GRACE_AFTER_MS + 3
|
||||
));
|
||||
assert!(!s.check_replay_for_test(0, 2, SecureDatagramDirection::AToB, expires_at_ms));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -152,6 +152,12 @@ pub(super) fn new_wasi_core_runtime(
|
||||
adapters.config.ignore_unsupported_config = true;
|
||||
adapters.config.endpoint_protocols = host_tunnel_schemes.to_vec();
|
||||
}
|
||||
// A WASI guest has no OS TUN, so the TCP subnet proxy can only forward through the
|
||||
// user-space stack. Advertise it whenever it is compiled in; never clear the flag, since
|
||||
// the host-tunnel mode above may rely on it independently of this feature.
|
||||
if cfg!(feature = "proxy-smoltcp-stack") {
|
||||
adapters.config.set_smoltcp_stack(true);
|
||||
}
|
||||
#[cfg(feature = "wasm-host-tunnel")]
|
||||
#[cfg(not(feature = "wasm-host-tunnel-outbound"))]
|
||||
let tunnel_ingress = crate::wasi::adapter::tunnel::WasiHostTunnelIngress::new(
|
||||
|
||||
Binary file not shown.
@@ -3,6 +3,6 @@
|
||||
package artifact
|
||||
|
||||
const (
|
||||
Commit = "c51437c673a71f69b4b028c8d566a7b23b33e65c"
|
||||
SHA256 = "17d6dd4561c1f1e818e7d70ed031c3cf8ea5dcfc1fd0461a6753f1f23f5fa6ab"
|
||||
Commit = "5f1eaa7c9f9d3e89e9b75a6d87531a436fae40c4"
|
||||
SHA256 = "de332a01eb4141e2e86ecee89500c40cc7d07a822a381868f95bb231bfc3739c"
|
||||
)
|
||||
|
||||
@@ -93,8 +93,11 @@ type InstanceConfigPatch struct {
|
||||
VpnPortalClients []*VpnPortalClientPatch `protobuf:"bytes,15,rep,name=vpn_portal_clients,json=vpnPortalClients,proto3" json:"vpn_portal_clients,omitempty"`
|
||||
ManagedCredentials *manage.ManagedCredentialSet `protobuf:"bytes,16,opt,name=managed_credentials,json=managedCredentials,proto3" json:"managed_credentials,omitempty"`
|
||||
PreferPeerRelay *bool `protobuf:"varint,17,opt,name=prefer_peer_relay,json=preferPeerRelay,proto3,oneof" json:"prefer_peer_relay,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
// Applies with routes: true preserves an empty manual list; false restores
|
||||
// automatic routes. Omission preserves legacy RoutePatch behavior.
|
||||
EnableManualRoutes *bool `protobuf:"varint,18,opt,name=enable_manual_routes,json=enableManualRoutes,proto3,oneof" json:"enable_manual_routes,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *InstanceConfigPatch) Reset() {
|
||||
@@ -246,6 +249,13 @@ func (x *InstanceConfigPatch) GetPreferPeerRelay() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *InstanceConfigPatch) GetEnableManualRoutes() bool {
|
||||
if x != nil && x.EnableManualRoutes != nil {
|
||||
return *x.EnableManualRoutes
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type VpnPortalClientPatch struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Action ConfigPatchAction `protobuf:"varint,1,opt,name=action,proto3,enum=api.config.ConfigPatchAction" json:"action,omitempty"`
|
||||
@@ -867,7 +877,7 @@ var File_api_config_proto protoreflect.FileDescriptor
|
||||
const file_api_config_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\x10api_config.proto\x12\n" +
|
||||
"api.config\x1a\fcommon.proto\x1a\tacl.proto\x1a\x12api_instance.proto\x1a\x10api_manage.proto\"\x86\t\n" +
|
||||
"api.config\x1a\fcommon.proto\x1a\tacl.proto\x1a\x12api_instance.proto\x1a\x10api_manage.proto\"\xd6\t\n" +
|
||||
"\x13InstanceConfigPatch\x12\x1f\n" +
|
||||
"\bhostname\x18\x01 \x01(\tH\x00R\bhostname\x88\x01\x01\x12)\n" +
|
||||
"\x04ipv4\x18\x02 \x01(\v2\x10.common.Ipv4InetH\x01R\x04ipv4\x88\x01\x01\x12)\n" +
|
||||
@@ -889,7 +899,8 @@ const file_api_config_proto_rawDesc = "" +
|
||||
"\x12disable_relay_data\x18\x0e \x01(\bH\aR\x10disableRelayData\x88\x01\x01\x12N\n" +
|
||||
"\x12vpn_portal_clients\x18\x0f \x03(\v2 .api.config.VpnPortalClientPatchR\x10vpnPortalClients\x12Q\n" +
|
||||
"\x13managed_credentials\x18\x10 \x01(\v2 .api.manage.ManagedCredentialSetR\x12managedCredentials\x12/\n" +
|
||||
"\x11prefer_peer_relay\x18\x11 \x01(\bH\bR\x0fpreferPeerRelay\x88\x01\x01B\v\n" +
|
||||
"\x11prefer_peer_relay\x18\x11 \x01(\bH\bR\x0fpreferPeerRelay\x88\x01\x01\x125\n" +
|
||||
"\x14enable_manual_routes\x18\x12 \x01(\bH\tR\x12enableManualRoutes\x88\x01\x01B\v\n" +
|
||||
"\t_hostnameB\a\n" +
|
||||
"\x05_ipv4B\a\n" +
|
||||
"\x05_ipv6B\x06\n" +
|
||||
@@ -898,7 +909,8 @@ const file_api_config_proto_rawDesc = "" +
|
||||
"\x16_ipv6_public_addr_autoB\x1a\n" +
|
||||
"\x18_ipv6_public_addr_prefixB\x15\n" +
|
||||
"\x13_disable_relay_dataB\x14\n" +
|
||||
"\x12_prefer_peer_relay\"\x88\x01\n" +
|
||||
"\x12_prefer_peer_relayB\x17\n" +
|
||||
"\x15_enable_manual_routes\"\x88\x01\n" +
|
||||
"\x14VpnPortalClientPatch\x125\n" +
|
||||
"\x06action\x18\x01 \x01(\x0e2\x1d.api.config.ConfigPatchActionR\x06action\x129\n" +
|
||||
"\x06client\x18\x02 \x01(\v2!.api.manage.VpnPortalClientConfigR\x06client\"x\n" +
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
package proto
|
||||
|
||||
const (
|
||||
EasyTierCommit = "c51437c673a71f69b4b028c8d566a7b23b33e65c"
|
||||
SchemaSHA256 = "e49a4af09e5b66089eebb73fda78ffca7e8468a5a938cf7a593d689881686f0a"
|
||||
EasyTierCommit = "5f1eaa7c9f9d3e89e9b75a6d87531a436fae40c4"
|
||||
SchemaSHA256 = "33516bc93a3fd43df341ce081a904c1f8c5fd00ea5c33d78925e2359312a926c"
|
||||
)
|
||||
|
||||
@@ -95,8 +95,11 @@ type HeartbeatRequest struct {
|
||||
SupportConfigSource bool `protobuf:"varint,9,opt,name=support_config_source,json=supportConfigSource,proto3" json:"support_config_source,omitempty"`
|
||||
FailedNetworkInstances []*common.UUID `protobuf:"bytes,10,rep,name=failed_network_instances,json=failedNetworkInstances,proto3" json:"failed_network_instances,omitempty"`
|
||||
SupportHeartbeatPolicy bool `protobuf:"varint,11,opt,name=support_heartbeat_policy,json=supportHeartbeatPolicy,proto3" json:"support_heartbeat_policy,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
// Supports explicit manual mode patches and typed empty-manual readback.
|
||||
// Older peers silently ignore unknown patch fields; missing means false.
|
||||
SupportManualRoutesPatch bool `protobuf:"varint,12,opt,name=support_manual_routes_patch,json=supportManualRoutesPatch,proto3" json:"support_manual_routes_patch,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *HeartbeatRequest) Reset() {
|
||||
@@ -206,6 +209,13 @@ func (x *HeartbeatRequest) GetSupportHeartbeatPolicy() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *HeartbeatRequest) GetSupportManualRoutesPatch() bool {
|
||||
if x != nil {
|
||||
return x.SupportManualRoutesPatch
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type HeartbeatResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
HeartbeatIntervalMs *uint32 `protobuf:"varint,1,opt,name=heartbeat_interval_ms,json=heartbeatIntervalMs,proto3,oneof" json:"heartbeat_interval_ms,omitempty"`
|
||||
@@ -346,7 +356,7 @@ const file_web_proto_rawDesc = "" +
|
||||
"\fDeviceOsInfo\x12\x17\n" +
|
||||
"\aos_type\x18\x01 \x01(\tR\x06osType\x12\x18\n" +
|
||||
"\aversion\x18\x02 \x01(\tR\aversion\x12\"\n" +
|
||||
"\fdistribution\x18\x03 \x01(\tR\fdistribution\"\x9d\x04\n" +
|
||||
"\fdistribution\x18\x03 \x01(\tR\fdistribution\"\xdc\x04\n" +
|
||||
"\x10HeartbeatRequest\x12+\n" +
|
||||
"\n" +
|
||||
"machine_id\x18\x01 \x01(\v2\f.common.UUIDR\tmachineId\x12%\n" +
|
||||
@@ -362,7 +372,8 @@ const file_web_proto_rawDesc = "" +
|
||||
"\x15support_config_source\x18\t \x01(\bR\x13supportConfigSource\x12F\n" +
|
||||
"\x18failed_network_instances\x18\n" +
|
||||
" \x03(\v2\f.common.UUIDR\x16failedNetworkInstances\x128\n" +
|
||||
"\x18support_heartbeat_policy\x18\v \x01(\bR\x16supportHeartbeatPolicy\"\xb6\x01\n" +
|
||||
"\x18support_heartbeat_policy\x18\v \x01(\bR\x16supportHeartbeatPolicy\x12=\n" +
|
||||
"\x1bsupport_manual_routes_patch\x18\f \x01(\bR\x18supportManualRoutesPatch\"\xb6\x01\n" +
|
||||
"\x11HeartbeatResponse\x127\n" +
|
||||
"\x15heartbeat_interval_ms\x18\x01 \x01(\rH\x00R\x13heartbeatIntervalMs\x88\x01\x01\x125\n" +
|
||||
"\x14heartbeat_timeout_ms\x18\x02 \x01(\rH\x01R\x12heartbeatTimeoutMs\x88\x01\x01B\x18\n" +
|
||||
|
||||
@@ -186,6 +186,143 @@ func testTCPDataPlane(
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("blocked TCP read did not wake: %v", ctx.Err())
|
||||
}
|
||||
|
||||
// FIN gate: Close must half-close the stream so the peer's read ends with
|
||||
// io.EOF. Releasing the resource without shutdown_write drops the socket
|
||||
// instead, which leaves the peer ESTABLISHED forever (the exit-node leak).
|
||||
if err := serverConnection.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
|
||||
t.Fatalf("set server read deadline: %v", err)
|
||||
}
|
||||
if _, err := serverConnection.Read(make([]byte, 1)); !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("server read after client close = %v, want io.EOF (FIN not sent)", err)
|
||||
}
|
||||
if err := serverConnection.Close(); err != nil {
|
||||
t.Fatalf("server close after EOF: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Application deadlines must not prevent the guest from closing TCP.
|
||||
func TestTCPCloseAfterExpiredDeadlines(t *testing.T) {
|
||||
past := time.Now().Add(-time.Second)
|
||||
for name, expire := range map[string]func(net.Conn) error{
|
||||
"write": func(c net.Conn) error { return c.SetWriteDeadline(past) },
|
||||
"full": func(c net.Conn) error { return c.SetDeadline(past) },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
host, server, client := startDataPlanePair(t, ctx)
|
||||
defer host.Close(ctx)
|
||||
defer server.Close(ctx)
|
||||
defer client.Close(ctx)
|
||||
|
||||
listener := listenTCPEventually(t, ctx, server)
|
||||
defer listener.Close()
|
||||
accepted := make(chan connectionResult, 1)
|
||||
go func() {
|
||||
connection, err := listener.Accept()
|
||||
accepted <- connectionResult{connection: connection, err: err}
|
||||
}()
|
||||
clientConnection := dialEventually(t, ctx, client,
|
||||
fmt.Sprintf("10.144.0.101:%d", listener.Addr().(*net.TCPAddr).Port))
|
||||
var serverConnection net.Conn
|
||||
select {
|
||||
case result := <-accepted:
|
||||
if result.err != nil {
|
||||
t.Fatalf("accept EasyTier TCP: %v", result.err)
|
||||
}
|
||||
serverConnection = result.connection
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("accept EasyTier TCP: %v", ctx.Err())
|
||||
}
|
||||
|
||||
if err := expire(clientConnection); err != nil {
|
||||
t.Fatalf("set expired client deadline: %v", err)
|
||||
}
|
||||
if err := clientConnection.Close(); err != nil {
|
||||
t.Fatalf("client close after expired deadline: %v", err)
|
||||
}
|
||||
if err := serverConnection.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
|
||||
t.Fatalf("set server read deadline: %v", err)
|
||||
}
|
||||
if _, err := serverConnection.Read(make([]byte, 1)); !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("server read after expired-deadline close = %v, want io.EOF", err)
|
||||
}
|
||||
if err := expire(serverConnection); err != nil {
|
||||
t.Fatalf("set expired server deadline: %v", err)
|
||||
}
|
||||
if err := serverConnection.Close(); err != nil {
|
||||
t.Fatalf("server close after expired deadline: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTCPCloseWithBlockedWrite(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
host, server, client := startDataPlanePair(t, ctx)
|
||||
defer host.Close(ctx)
|
||||
defer server.Close(ctx)
|
||||
defer client.Close(ctx)
|
||||
|
||||
listener := listenTCPEventually(t, ctx, server)
|
||||
defer listener.Close()
|
||||
accepted := make(chan connectionResult, 1)
|
||||
go func() {
|
||||
connection, err := listener.Accept()
|
||||
accepted <- connectionResult{connection: connection, err: err}
|
||||
}()
|
||||
clientConnection := dialEventually(t, ctx, client,
|
||||
fmt.Sprintf("10.144.0.101:%d", listener.Addr().(*net.TCPAddr).Port))
|
||||
defer clientConnection.Close()
|
||||
var serverConnection net.Conn
|
||||
select {
|
||||
case result := <-accepted:
|
||||
if result.err != nil {
|
||||
t.Fatalf("accept EasyTier TCP: %v", result.err)
|
||||
}
|
||||
serverConnection = result.connection
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("accept EasyTier TCP: %v", ctx.Err())
|
||||
}
|
||||
defer serverConnection.Close()
|
||||
if err := serverConnection.SetReadDeadline(time.Now().Add(10 * time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
written := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := clientConnection.Write(make([]byte, 1024*1024))
|
||||
written <- err
|
||||
}()
|
||||
// Observe real data before withholding reads to fill the TCP windows.
|
||||
if _, err := io.ReadFull(serverConnection, make([]byte, 1)); err != nil {
|
||||
t.Fatalf("read start of blocked write: %v", err)
|
||||
}
|
||||
select {
|
||||
case err := <-written:
|
||||
t.Fatalf("large write completed before Close: %v", err)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
if err := clientConnection.Close(); err != nil {
|
||||
t.Fatalf("close with blocked write: %v", err)
|
||||
}
|
||||
select {
|
||||
case err := <-written:
|
||||
if !errors.Is(err, net.ErrClosed) {
|
||||
t.Fatalf("blocked write after Close = %v, want net.ErrClosed", err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("Close did not unblock Write: %v", ctx.Err())
|
||||
}
|
||||
// Bytes already accepted by TCP precede FIN; draining them must end in EOF.
|
||||
if _, err := io.Copy(io.Discard, serverConnection); err != nil {
|
||||
t.Fatalf("server read after Close = %v, want EOF", err)
|
||||
}
|
||||
if err := serverConnection.Close(); err != nil {
|
||||
t.Fatalf("server close after EOF: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testUDPDataPlane(
|
||||
|
||||
@@ -7,9 +7,12 @@
|
||||
"./*.sys"
|
||||
],
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"installerHooks": "./windows/installer-hooks.nsh"
|
||||
},
|
||||
"webviewInstallMode": {
|
||||
"type": "embedBootstrapper"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Windows installer process handling
|
||||
|
||||
NSIS remains a current-user installer. Its helper requests elevation only
|
||||
when a running service or GUI requires it, and restores service startup and
|
||||
running state after installation. During an update it temporarily disables
|
||||
service startup because released EasyTier versions can trigger SCM recovery
|
||||
even after an explicit stop.
|
||||
|
||||
Run `test-installer-processes.ps1` with Windows PowerShell as administrator
|
||||
on a test machine without an existing `easytier-gui` service. The test uses
|
||||
isolated executables and a service that reproduces the old exit behavior
|
||||
with failure recovery enabled. Run it with `-GuiOnly` from an unelevated
|
||||
shell to check the path that needs no elevation.
|
||||
|
||||
## Known limitations
|
||||
|
||||
If preparation on the welcome page has already started a helper, it is
|
||||
reused at installation time. Changing to another directory containing a
|
||||
running GUI, or relaunching the GUI between those pages, may still cause
|
||||
Tauri's default process check to fail. Rechecking those processes is a
|
||||
separate follow-up; it was recorded as a minor finding in the final review.
|
||||
|
||||
Executable matching does not expand Windows 8.3 short-path aliases. An
|
||||
installation directory expressed using a short alias may not match the
|
||||
process image's full path. This was reproduced with LocalService's short
|
||||
temporary-directory path; the unprivileged GUI test passes when its
|
||||
temporary directory uses the full path. This is recorded as a minor
|
||||
follow-up. Use a full temporary-directory path when running the fixture.
|
||||
@@ -0,0 +1,95 @@
|
||||
!define EASYTIER_INSTALLER_SCRIPT "${__FILEDIR__}\installer-processes.ps1"
|
||||
|
||||
; Tauri uninstalls the old version on its reinstall page, before PREINSTALL.
|
||||
; MUI consumes this callback on the first page (Welcome). Opening the installer
|
||||
; alone does not stop anything; the user must first leave the welcome page.
|
||||
!define MUI_PAGE_CUSTOMFUNCTION_LEAVE EasyTierPrepareInstall
|
||||
!define MUI_CUSTOMFUNCTION_GUIINIT EasyTierInstallerGuiInit
|
||||
|
||||
Var EasyTierHelper
|
||||
Var EasyTierInstallerPid
|
||||
Var EasyTierDone
|
||||
Var EasyTierFailed
|
||||
|
||||
!macro EasyTierPrepare RESTORE
|
||||
${If} $EasyTierHelper != ""
|
||||
Return
|
||||
${EndIf}
|
||||
InitPluginsDir
|
||||
File "/oname=$PLUGINSDIR\installer-processes.ps1" "${EASYTIER_INSTALLER_SCRIPT}"
|
||||
System::Call 'kernel32::GetCurrentProcessId() i .r0'
|
||||
StrCpy $EasyTierInstallerPid $0
|
||||
nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\installer-processes.ps1" -InstallDir "$INSTDIR" -InstallerPid $EasyTierInstallerPid -RestoreService ${RESTORE}'
|
||||
Pop $0
|
||||
Pop $1
|
||||
${If} $0 != 0
|
||||
MessageBox MB_OK|MB_ICONSTOP "Could not close EasyTier:$\r$\n$1" /SD IDOK
|
||||
Abort
|
||||
${EndIf}
|
||||
IntOp $1 $1 + 0
|
||||
${If} $1 != 0
|
||||
System::Call 'kernel32::OpenEventW(i 0x100000, i 0, w "Local\EasyTierInstaller-$EasyTierInstallerPid-done") p .r0'
|
||||
StrCpy $EasyTierDone $0
|
||||
System::Call 'kernel32::OpenEventW(i 0x100000, i 0, w "Local\EasyTierInstaller-$EasyTierInstallerPid-failed") p .r0'
|
||||
StrCpy $EasyTierFailed $0
|
||||
${If} $EasyTierDone == 0
|
||||
${OrIf} $EasyTierFailed == 0
|
||||
MessageBox MB_OK|MB_ICONSTOP "Could not wait for EasyTier to finish updating." /SD IDOK
|
||||
Abort
|
||||
${EndIf}
|
||||
StrCpy $EasyTierHelper 1
|
||||
${EndIf}
|
||||
!macroend
|
||||
|
||||
!macro EasyTierFinish
|
||||
${If} $EasyTierHelper != ""
|
||||
System::Call 'kernel32::OpenEventW(i 2, i 0, w "Local\EasyTierInstaller-$EasyTierInstallerPid-finish") p .r0'
|
||||
${If} $0 != 0
|
||||
System::Call 'kernel32::SetEvent(p r0)'
|
||||
System::Call 'kernel32::CloseHandle(p r0)'
|
||||
${EndIf}
|
||||
System::Call 'kernel32::WaitForSingleObject(p $EasyTierDone, i 65000) i .r0'
|
||||
System::Call 'kernel32::WaitForSingleObject(p $EasyTierFailed, i 0) i .r1'
|
||||
System::Call 'kernel32::CloseHandle(p $EasyTierDone)'
|
||||
System::Call 'kernel32::CloseHandle(p $EasyTierFailed)'
|
||||
StrCpy $EasyTierHelper ""
|
||||
${If} $0 != 0
|
||||
${OrIf} $1 != 258
|
||||
MessageBox MB_OK|MB_ICONSTOP "Could not restore the EasyTier service. Please check its status in Windows Services." /SD IDOK
|
||||
Abort
|
||||
${EndIf}
|
||||
${EndIf}
|
||||
!macroend
|
||||
|
||||
Function EasyTierPrepareInstall
|
||||
!insertmacro EasyTierPrepare 1
|
||||
FunctionEnd
|
||||
|
||||
Function EasyTierInstallerGuiInit
|
||||
; Passive installs skip Welcome, but still run the old uninstaller page.
|
||||
${GetOptions} $CMDLINE "/P" $0
|
||||
${IfNot} ${Errors}
|
||||
Call EasyTierPrepareInstall
|
||||
${EndIf}
|
||||
FunctionEnd
|
||||
|
||||
Function un.EasyTierPrepareUninstall
|
||||
!insertmacro EasyTierPrepare 0
|
||||
FunctionEnd
|
||||
|
||||
!macro NSIS_HOOK_PREINSTALL
|
||||
; Silent installs skip all pages. Also handle a fresh/custom install path.
|
||||
Call EasyTierPrepareInstall
|
||||
!macroend
|
||||
|
||||
!macro NSIS_HOOK_POSTINSTALL
|
||||
!insertmacro EasyTierFinish
|
||||
!macroend
|
||||
|
||||
!macro NSIS_HOOK_PREUNINSTALL
|
||||
Call un.EasyTierPrepareUninstall
|
||||
!macroend
|
||||
|
||||
!macro NSIS_HOOK_POSTUNINSTALL
|
||||
!insertmacro EasyTierFinish
|
||||
!macroend
|
||||
@@ -0,0 +1,264 @@
|
||||
param(
|
||||
[ValidateSet('Prepare', 'Run')]
|
||||
[string]$Action = 'Prepare',
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$InstallDir,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int]$InstallerPid,
|
||||
[int]$RestoreService = 1
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$eventName = "Local\EasyTierInstaller-$InstallerPid"
|
||||
$exePath = [IO.Path]::GetFullPath((Join-Path $InstallDir 'easytier-gui.exe')).Replace('\\?\', '')
|
||||
Add-Type -AssemblyName System.ServiceProcess
|
||||
|
||||
# Process.MainModule and WMI cannot reliably read an elevated GUI's path from
|
||||
# the unelevated installer. Limited query access works across that boundary.
|
||||
Add-Type @'
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
public static class EasyTierInstallerProcess
|
||||
{
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
public static extern IntPtr OpenProcess(uint access, bool inherit, int pid);
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
static extern bool QueryFullProcessImageName(IntPtr process, uint flags,
|
||||
StringBuilder path, ref int size);
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
static extern bool TerminateProcess(IntPtr process, uint exitCode);
|
||||
[DllImport("kernel32.dll")]
|
||||
static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds);
|
||||
[DllImport("kernel32.dll")]
|
||||
public static extern bool CloseHandle(IntPtr handle);
|
||||
|
||||
public static string Path(IntPtr process)
|
||||
{
|
||||
var path = new StringBuilder(32768);
|
||||
int size = path.Capacity;
|
||||
if (!QueryFullProcessImageName(process, 0, path, ref size))
|
||||
{
|
||||
int error = Marshal.GetLastWin32Error();
|
||||
if (WaitForSingleObject(process, 0) == 0) return null;
|
||||
throw new Win32Exception(error);
|
||||
}
|
||||
return path.ToString().Replace(@"\\?\", "");
|
||||
}
|
||||
|
||||
public static bool Matches(int pid, string path, bool terminate)
|
||||
{
|
||||
// Keep the same handle for path verification, termination and waiting,
|
||||
// so a recycled PID cannot cause us to terminate a different process.
|
||||
IntPtr process = OpenProcess(terminate ? 0x101001u : 0x101000u, false, pid);
|
||||
if (process == IntPtr.Zero)
|
||||
{
|
||||
int error = Marshal.GetLastWin32Error();
|
||||
if (error == 87) return false; // The process has already exited.
|
||||
throw new Win32Exception(error);
|
||||
}
|
||||
try
|
||||
{
|
||||
if (!String.Equals(Path(process), path, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
if (terminate)
|
||||
{
|
||||
if (!TerminateProcess(process, 0) && WaitForSingleObject(process, 0) != 0)
|
||||
throw new Win32Exception();
|
||||
if (WaitForSingleObject(process, 30000) != 0)
|
||||
throw new TimeoutException("EasyTier did not exit within 30 seconds.");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
finally { CloseHandle(process); }
|
||||
}
|
||||
}
|
||||
'@
|
||||
|
||||
function Get-AppProcesses {
|
||||
foreach ($process in @(Get-Process -Name easytier-gui -ErrorAction SilentlyContinue)) {
|
||||
if ([EasyTierInstallerProcess]::Matches($process.Id, $exePath, $false)) {
|
||||
$process
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-AppService {
|
||||
$service = Get-WmiObject -Class Win32_Service -Filter "Name='easytier-gui'"
|
||||
if ($service) {
|
||||
$path = [regex]::Match($service.PathName, '^(?:"([^"]+)"|(\S+))')
|
||||
$binary = ($path.Groups[1].Value + $path.Groups[2].Value).Replace('\\?\', '')
|
||||
if ($binary -eq $exePath) { $service }
|
||||
}
|
||||
}
|
||||
|
||||
function New-InstallerEvent([string]$name) {
|
||||
$security = New-Object Security.AccessControl.EventWaitHandleSecurity
|
||||
# RunAs may use another administrator account. The installer keeps its
|
||||
# original identity; only these completion signals cross the boundary.
|
||||
foreach ($sid in @(
|
||||
[Security.Principal.WindowsIdentity]::GetCurrent().User,
|
||||
(New-Object Security.Principal.SecurityIdentifier 'S-1-5-32-544')
|
||||
)) {
|
||||
$rule = New-Object Security.AccessControl.EventWaitHandleAccessRule(
|
||||
$sid, 'FullControl', 'Allow')
|
||||
$security.AddAccessRule($rule)
|
||||
}
|
||||
$created = $false
|
||||
New-Object Threading.EventWaitHandle(
|
||||
$false, [Threading.EventResetMode]::ManualReset, $name, [ref]$created, $security)
|
||||
}
|
||||
|
||||
try {
|
||||
if ($Action -eq 'Prepare') {
|
||||
$service = Get-AppService
|
||||
$runningService = $service -and $service.State -ne 'Stopped'
|
||||
$needsElevation = $runningService
|
||||
$processes = @()
|
||||
try {
|
||||
$processes = @(Get-AppProcesses)
|
||||
foreach ($process in $processes) {
|
||||
$handle = [EasyTierInstallerProcess]::OpenProcess(0x101001, $false, $process.Id)
|
||||
if ($handle -eq [IntPtr]::Zero) { $needsElevation = $true }
|
||||
else { [void][EasyTierInstallerProcess]::CloseHandle($handle) }
|
||||
}
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.GetBaseException().NativeErrorCode -ne 5) { throw }
|
||||
$needsElevation = $true
|
||||
}
|
||||
if (!$runningService -and !$processes.Count -and !$needsElevation) {
|
||||
Write-Output 0
|
||||
exit 0
|
||||
}
|
||||
|
||||
$ready = New-InstallerEvent "$eventName-ready"
|
||||
$finish = New-InstallerEvent "$eventName-finish"
|
||||
$done = New-InstallerEvent "$eventName-done"
|
||||
$failed = New-InstallerEvent "$eventName-failed"
|
||||
try {
|
||||
$arguments = '-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass ' +
|
||||
"-File `"$PSCommandPath`" -Action Run -InstallDir `"$InstallDir`" " +
|
||||
"-InstallerPid $InstallerPid -RestoreService $RestoreService"
|
||||
$start = @{
|
||||
FilePath = "$PSHOME\powershell.exe"
|
||||
ArgumentList = $arguments
|
||||
WindowStyle = 'Hidden'
|
||||
PassThru = $true
|
||||
}
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
|
||||
if ($needsElevation -and !$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
$start.Verb = 'RunAs'
|
||||
}
|
||||
$helper = Start-Process @start
|
||||
while (!$ready.WaitOne(100)) {
|
||||
if ($helper.HasExited) {
|
||||
throw 'Could not stop EasyTier.'
|
||||
}
|
||||
}
|
||||
if ($failed.WaitOne(0)) { throw 'Could not stop EasyTier.' }
|
||||
Write-Output $helper.Id
|
||||
}
|
||||
finally {
|
||||
$ready.Dispose()
|
||||
$finish.Dispose()
|
||||
$done.Dispose()
|
||||
$failed.Dispose()
|
||||
}
|
||||
}
|
||||
else {
|
||||
$ready = [Threading.EventWaitHandle]::OpenExisting("$eventName-ready")
|
||||
$finish = [Threading.EventWaitHandle]::OpenExisting("$eventName-finish")
|
||||
$done = [Threading.EventWaitHandle]::OpenExisting("$eventName-done")
|
||||
$failed = [Threading.EventWaitHandle]::OpenExisting("$eventName-failed")
|
||||
$installer = [Diagnostics.Process]::GetProcessById($InstallerPid)
|
||||
$installerExit = New-Object Threading.ManualResetEvent($false)
|
||||
$installerExit.SafeWaitHandle = New-Object Microsoft.Win32.SafeHandles.SafeWaitHandle(
|
||||
$installer.Handle, $false)
|
||||
$restart = $false
|
||||
$controller = $null
|
||||
$serviceProcess = $null
|
||||
$startMode = $null
|
||||
try {
|
||||
$service = Get-AppService
|
||||
if ($service -and $service.State -ne 'Stopped') {
|
||||
$controller = New-Object ServiceProcess.ServiceController('easytier-gui')
|
||||
$restart = $RestoreService -ne 0 -and $service.State -ne 'Stop Pending' -and
|
||||
$service.StartMode -ne 'Disabled'
|
||||
if ($service.StartMode -ne 'Disabled') {
|
||||
# Released versions can exit without reporting Stopped,
|
||||
# which queues SCM recovery even after an explicit stop.
|
||||
# Keep recovery from starting the old binary during setup.
|
||||
$originalStartMode = $service.StartMode
|
||||
$result = $service.ChangeStartMode('Disabled').ReturnValue
|
||||
if ($result -ne 0) { throw "Could not disable service startup: $result" }
|
||||
$startMode = if ($originalStartMode -eq 'Auto') { 'Automatic' } else { 'Manual' }
|
||||
}
|
||||
if ($service.ProcessId) {
|
||||
$serviceProcess = [Diagnostics.Process]::GetProcessById($service.ProcessId)
|
||||
# Acquire the handle before stopping, while the PID still exists.
|
||||
$null = $serviceProcess.Handle
|
||||
}
|
||||
if ($controller.Status -eq 'StartPending') {
|
||||
$controller.WaitForStatus('Running', [TimeSpan]::FromSeconds(30))
|
||||
}
|
||||
if ($controller.Status -ne 'StopPending') { $controller.Stop() }
|
||||
$controller.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(30))
|
||||
if ($serviceProcess -and !$serviceProcess.WaitForExit(30000)) {
|
||||
throw 'The EasyTier service process did not exit within 30 seconds.'
|
||||
}
|
||||
}
|
||||
foreach ($process in @(Get-AppProcesses)) {
|
||||
[void][EasyTierInstallerProcess]::Matches($process.Id, $exePath, $true)
|
||||
}
|
||||
[void]$ready.Set()
|
||||
[void][Threading.WaitHandle]::WaitAny(@($finish, $installerExit))
|
||||
}
|
||||
finally {
|
||||
# Also restore after cancellation/failure, provided the executable
|
||||
# still exists. A completed uninstall must not restart the service.
|
||||
try {
|
||||
if ($startMode) {
|
||||
$result = $service.ChangeStartMode($startMode).ReturnValue
|
||||
if ($result -ne 0) { throw "Could not restore service startup: $result" }
|
||||
}
|
||||
if ($restart -and (Test-Path -LiteralPath $exePath)) {
|
||||
$controller.Refresh()
|
||||
if ($controller.Status -eq 'StopPending') {
|
||||
$controller.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(30))
|
||||
}
|
||||
if ($controller.Status -eq 'Stopped') {
|
||||
$controller.Start()
|
||||
$controller.WaitForStatus('Running', [TimeSpan]::FromSeconds(30))
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($controller) { $controller.Dispose() }
|
||||
if ($serviceProcess) { $serviceProcess.Dispose() }
|
||||
$installerExit.Dispose()
|
||||
$installer.Dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
if ($Action -eq 'Run' -and $failed) { [void]$failed.Set() }
|
||||
Write-Error $_ -ErrorAction Continue
|
||||
exit 1
|
||||
}
|
||||
finally {
|
||||
if ($Action -eq 'Run' -and $done) {
|
||||
# Completion events remain accessible to the original user even when
|
||||
# UAC ran the helper under a different administrator account.
|
||||
[void]$done.Set()
|
||||
[void]$ready.Set()
|
||||
$ready.Dispose()
|
||||
$finish.Dispose()
|
||||
$done.Dispose()
|
||||
$failed.Dispose()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
# Run with Windows PowerShell as administrator on a machine without an
|
||||
# installed easytier-gui service. All processes and files are test fixtures.
|
||||
# Use -GuiOnly from an unelevated shell to test the path that needs no UAC.
|
||||
param([switch]$GuiOnly)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$helperScript = Join-Path $PSScriptRoot 'installer-processes.ps1'
|
||||
$testDir = Join-Path $env:TEMP ("EasyTier installer test " + [guid]::NewGuid())
|
||||
$appDir = Join-Path $testDir 'app'
|
||||
$otherDir = Join-Path $testDir 'other'
|
||||
$exe = Join-Path $appDir 'easytier-gui.exe'
|
||||
$helper = $null
|
||||
$gui = $null
|
||||
$other = $null
|
||||
$parent = $null
|
||||
$createdService = $false
|
||||
|
||||
function Assert($condition, [string]$message) {
|
||||
if (!$condition) { throw $message }
|
||||
}
|
||||
|
||||
function Prepare([int]$parentPid = $PID, [int]$restore = 1) {
|
||||
$output = & "$PSHOME\powershell.exe" -NoProfile -ExecutionPolicy Bypass `
|
||||
-File $helperScript -InstallDir $appDir -InstallerPid $parentPid -RestoreService $restore
|
||||
Assert ($LASTEXITCODE -eq 0) "Prepare failed: $output"
|
||||
$helperPid = [int]($output | Select-Object -Last 1)
|
||||
if ($helperPid) {
|
||||
$process = [Diagnostics.Process]::GetProcessById($helperPid)
|
||||
$null = $process.Handle
|
||||
return $process
|
||||
}
|
||||
}
|
||||
|
||||
function Finish($process) {
|
||||
if (!$process) { return }
|
||||
$finish = [Threading.EventWaitHandle]::OpenExisting("Local\EasyTierInstaller-$PID-finish")
|
||||
$done = [Threading.EventWaitHandle]::OpenExisting("Local\EasyTierInstaller-$PID-done")
|
||||
$failed = [Threading.EventWaitHandle]::OpenExisting("Local\EasyTierInstaller-$PID-failed")
|
||||
try {
|
||||
[void]$finish.Set()
|
||||
Assert ($done.WaitOne(65000)) 'The helper did not signal completion.'
|
||||
Assert (!$failed.WaitOne(0)) 'The helper reported a failure.'
|
||||
}
|
||||
finally {
|
||||
$finish.Dispose()
|
||||
$done.Dispose()
|
||||
$failed.Dispose()
|
||||
}
|
||||
Assert ($process.WaitForExit(5000)) 'The helper did not finish.'
|
||||
Assert ($process.ExitCode -eq 0) "The helper failed: $($process.ExitCode)"
|
||||
$process.Dispose()
|
||||
}
|
||||
|
||||
function Start-TestService {
|
||||
Start-Service easytier-gui
|
||||
(Get-Service easytier-gui).WaitForStatus('Running', [TimeSpan]::FromSeconds(10))
|
||||
}
|
||||
|
||||
if (Get-Service easytier-gui -ErrorAction SilentlyContinue) {
|
||||
throw 'An easytier-gui service already exists; refusing to touch it.'
|
||||
}
|
||||
try {
|
||||
[void](New-Item -ItemType Directory -Path $appDir, $otherDir)
|
||||
Add-Type -ReferencedAssemblies System.ServiceProcess -OutputAssembly $exe -OutputType ConsoleApplication @'
|
||||
using System;
|
||||
using System.ServiceProcess;
|
||||
using System.Threading;
|
||||
|
||||
class FixtureService : ServiceBase
|
||||
{
|
||||
static bool legacy;
|
||||
public FixtureService() { ServiceName = "easytier-gui"; }
|
||||
protected override void OnStop()
|
||||
{
|
||||
// Model released EasyTier versions that exit without reporting Stopped.
|
||||
if (legacy) Environment.Exit(0);
|
||||
}
|
||||
static void Main(string[] args)
|
||||
{
|
||||
if (args.Length > 0)
|
||||
{
|
||||
legacy = args[0] == "--legacy";
|
||||
ServiceBase.Run(new FixtureService());
|
||||
}
|
||||
else Thread.Sleep(Timeout.Infinite);
|
||||
}
|
||||
}
|
||||
'@
|
||||
Copy-Item $exe (Join-Path $otherDir 'easytier-gui.exe')
|
||||
|
||||
$helper = Prepare
|
||||
Assert (!$helper) 'An idle installation must not start a helper.'
|
||||
Write-Host 'PASS: no running process or service'
|
||||
|
||||
$gui = Start-Process $exe -PassThru
|
||||
$other = Start-Process (Join-Path $otherDir 'easytier-gui.exe') -PassThru
|
||||
$helper = Prepare
|
||||
Assert $gui.HasExited 'The GUI was not stopped.'
|
||||
Assert (!$other.HasExited) 'A GUI from a different installation was stopped.'
|
||||
Finish $helper
|
||||
$helper = $null
|
||||
Write-Host 'PASS: close GUI, preserve a different installation'
|
||||
if ($GuiOnly) { return }
|
||||
|
||||
New-Service -Name easytier-gui -BinaryPathName "`"$exe`" --legacy" -StartupType Automatic | Out-Null
|
||||
$createdService = $true
|
||||
& "$env:SystemRoot\System32\sc.exe" failure easytier-gui reset= 0 actions= restart/1000 | Out-Null
|
||||
Assert ($LASTEXITCODE -eq 0) 'Could not configure service recovery.'
|
||||
Start-TestService
|
||||
$gui = Start-Process $exe -PassThru
|
||||
$helper = Prepare
|
||||
Start-Sleep -Milliseconds 1500
|
||||
Assert ((Get-Service easytier-gui).Status -eq 'Stopped') 'The service was not stopped.'
|
||||
Assert ((Get-WmiObject Win32_Service -Filter "Name='easytier-gui'").StartMode -eq 'Disabled') 'Service recovery was not inhibited.'
|
||||
Assert $gui.HasExited 'The GUI alongside the service was not stopped.'
|
||||
Move-Item $exe "$exe.old"
|
||||
Copy-Item "$exe.old" $exe
|
||||
Finish $helper
|
||||
$helper = $null
|
||||
Assert ((Get-Service easytier-gui).Status -eq 'Running') 'The running service was not restored.'
|
||||
Assert ((Get-WmiObject Win32_Service -Filter "Name='easytier-gui'").StartMode -eq 'Auto') 'Automatic startup was not restored.'
|
||||
Write-Host 'PASS: stop legacy service despite failure recovery, replace executable, restore service'
|
||||
|
||||
Set-Service easytier-gui -StartupType Disabled
|
||||
Stop-Service easytier-gui
|
||||
(Get-Service easytier-gui).WaitForStatus('Stopped', [TimeSpan]::FromSeconds(10))
|
||||
Start-Sleep -Milliseconds 1500
|
||||
Set-Service easytier-gui -StartupType Manual
|
||||
$helper = Prepare
|
||||
Assert (!$helper) 'A stopped service must not start a helper.'
|
||||
$gui = Start-Process $exe -PassThru
|
||||
$helper = Prepare
|
||||
Finish $helper
|
||||
$helper = $null
|
||||
Assert ((Get-Service easytier-gui).Status -eq 'Stopped') 'A previously stopped service was started.'
|
||||
Write-Host 'PASS: preserve stopped service state'
|
||||
|
||||
Start-TestService
|
||||
$parent = Start-Process "$PSHOME\powershell.exe" -ArgumentList '-NoProfile -Command Start-Sleep 300' -WindowStyle Hidden -PassThru
|
||||
$helper = Prepare $parent.Id
|
||||
Assert ((Get-Service easytier-gui).Status -eq 'Stopped') 'The service was not stopped before cancellation.'
|
||||
$parent.Kill()
|
||||
Assert ($helper.WaitForExit(35000)) 'The helper did not handle installer exit.'
|
||||
Assert ($helper.ExitCode -eq 0) 'The helper failed while handling installer exit.'
|
||||
$helper.Dispose()
|
||||
$helper = $null
|
||||
Assert ((Get-Service easytier-gui).Status -eq 'Running') 'Cancellation did not restore the service.'
|
||||
Assert ((Get-WmiObject Win32_Service -Filter "Name='easytier-gui'").StartMode -eq 'Manual') 'Manual startup was not restored.'
|
||||
Write-Host 'PASS: restore service when the installer exits'
|
||||
|
||||
$helper = Prepare $PID 0
|
||||
Finish $helper
|
||||
$helper = $null
|
||||
Assert ((Get-Service easytier-gui).Status -eq 'Stopped') 'Uninstallation restarted the service.'
|
||||
Write-Host 'PASS: do not restart the service after uninstall'
|
||||
}
|
||||
finally {
|
||||
if ($helper -and !$helper.HasExited) { Finish $helper }
|
||||
if ($createdService) {
|
||||
Set-Service easytier-gui -StartupType Disabled
|
||||
Stop-Service easytier-gui -ErrorAction SilentlyContinue
|
||||
& "$env:SystemRoot\System32\sc.exe" delete easytier-gui | Out-Null
|
||||
}
|
||||
foreach ($process in @($gui, $other, $parent)) {
|
||||
if ($process -and !$process.HasExited) { $process.Kill(); $process.WaitForExit() }
|
||||
if ($process) { $process.Dispose() }
|
||||
}
|
||||
Remove-Item -LiteralPath $testDir -Recurse -Force
|
||||
}
|
||||
@@ -6,6 +6,8 @@ use std::{env, path::PathBuf};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::io::Cursor;
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::path::Path;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn check_protoc_exist() -> Option<PathBuf> {
|
||||
@@ -68,17 +70,52 @@ fn download_protoc() -> PathBuf {
|
||||
fname
|
||||
}
|
||||
|
||||
/// Locate the well-known types include directory (containing
|
||||
/// `google/protobuf/descriptor.proto`) that ships with a protoc release
|
||||
/// next to the binary: `bin/protoc.exe` -> sibling `include/` directory.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn bundled_protoc_include(protoc: &Path) -> Option<PathBuf> {
|
||||
let include = protoc.parent()?.parent()?.join("include");
|
||||
include
|
||||
.join("google/protobuf/descriptor.proto")
|
||||
.is_file()
|
||||
.then_some(include)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn ensure_protoc_for_windows() {
|
||||
let protoc_path = if let Some(path) = check_protoc_exist() {
|
||||
println!("cargo:info=use os existing protoc: {:?}", path);
|
||||
path
|
||||
} else {
|
||||
download_protoc()
|
||||
};
|
||||
// Preserve an explicit include directory even when protoc is found on PATH
|
||||
// or downloaded. Only infer the bundled directory when it is not configured.
|
||||
let protoc_include = env::var_os("PROTOC_INCLUDE").map(PathBuf::from);
|
||||
if env::var_os("PROTOC").is_some() && protoc_include.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(path) = check_protoc_exist() {
|
||||
if let Some(include) = protoc_include
|
||||
.clone()
|
||||
.or_else(|| bundled_protoc_include(&path))
|
||||
{
|
||||
println!("cargo:info=use os existing protoc: {:?}", path);
|
||||
unsafe {
|
||||
env::set_var("PROTOC", path);
|
||||
env::set_var("PROTOC_INCLUDE", include);
|
||||
}
|
||||
return;
|
||||
}
|
||||
println!(
|
||||
"cargo:info=protoc {:?} has no bundled include dir, falling back to download",
|
||||
path
|
||||
);
|
||||
}
|
||||
|
||||
let protoc_path = download_protoc();
|
||||
let include = protoc_include
|
||||
.or_else(|| bundled_protoc_include(&protoc_path))
|
||||
.expect("downloaded protoc zip should contain include dir");
|
||||
unsafe {
|
||||
env::set_var("PROTOC", protoc_path);
|
||||
env::set_var("PROTOC_INCLUDE", include);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ message NetworkConfig {
|
||||
|
||||
optional bool use_smoltcp = 21;
|
||||
optional bool disable_ipv6 = 47;
|
||||
optional string tld_dns_zone = 73;
|
||||
optional bool enable_kcp_proxy = 22;
|
||||
optional bool disable_kcp_input = 23;
|
||||
optional bool disable_p2p = 24;
|
||||
|
||||
@@ -66,7 +66,7 @@ message Flags {
|
||||
bool disable_sym_hole_punching = 30 [(easytier.flag) = { default: "false" }];
|
||||
|
||||
// tld dns zone for magic dns
|
||||
string tld_dns_zone = 31 [(easytier.flag) = { default: "et.net." }];
|
||||
string tld_dns_zone = 31 [(easytier.flag) = { default: "et.net.", form: true }];
|
||||
|
||||
bool p2p_only = 32 [(easytier.flag) = { default: "false" }];
|
||||
|
||||
|
||||
@@ -168,6 +168,7 @@ function allFieldFixture() {
|
||||
mtu: 1280,
|
||||
mapped_listeners: ['tcp://127.0.0.1:13010'],
|
||||
enable_magic_dns: true,
|
||||
tld_dns_zone: 'nas.internal.',
|
||||
enable_private_mode: true,
|
||||
enable_quic_proxy: true,
|
||||
disable_quic_input: true,
|
||||
|
||||
@@ -387,6 +387,18 @@ function removeVpnPortalClient(index: number) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row gap-x-9 flex-wrap">
|
||||
<div class="flex flex-col gap-2 basis-5/12 grow">
|
||||
<div class="flex">
|
||||
<label for="tld_dns_zone">{{ t('tld_dns_zone') }}</label>
|
||||
<span class="pi pi-question-circle ml-2 self-center" v-tooltip="t('tld_dns_zone_help')"></span>
|
||||
</div>
|
||||
<InputText id="tld_dns_zone" :model-value="curNetwork.tld_dns_zone"
|
||||
@update:model-value="curNetwork.tld_dns_zone = $event?.trim() || undefined"
|
||||
aria-describedby="tld_dns_zone-help" :placeholder="t('tld_dns_zone_placeholder')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row gap-x-9 flex-wrap">
|
||||
<div class="flex flex-col gap-2 basis-5/12 grow">
|
||||
<label for="hostname">{{ t('hostname') }}</label>
|
||||
|
||||
@@ -255,7 +255,11 @@ disable_sym_hole_punching_help: 禁用对称NAT的打洞(生日攻击),将
|
||||
enable_magic_dns: 启用魔法DNS
|
||||
enable_magic_dns_help: |
|
||||
启用魔法DNS,允许通过EasyTier的DNS服务器访问其他节点的虚拟IPv4地址, 如 node1.et.net。
|
||||
|
||||
tld_dns_zone: DNS域名后缀
|
||||
tld_dns_zone_help: |
|
||||
自定义魔法DNS的顶级域名(TLD)后缀。默认为 "et.net."。
|
||||
如需使用其他域名后缀,可改为 "et.internal." 或 "easytier.lan" 等。
|
||||
tld_dns_zone_placeholder: 例如 et.net.
|
||||
enable_private_mode: 启用私有模式
|
||||
enable_private_mode_help: |
|
||||
启用私有模式,则不允许使用了与本网络不相同的网络名称和密码的节点通过本节点进行握手或中转。
|
||||
|
||||
@@ -254,7 +254,11 @@ disable_sym_hole_punching_help: Disable special hole punching handling for symme
|
||||
enable_magic_dns: Enable Magic DNS
|
||||
enable_magic_dns_help: |
|
||||
Enable magic dns, all nodes in the network can access each other by domain name, e.g.: node1.et.net.
|
||||
|
||||
tld_dns_zone: DNS Zone TLD
|
||||
tld_dns_zone_help: |
|
||||
Custom top-level domain (TLD) zone for Magic DNS hostnames. Default is "et.net.".
|
||||
Change this if you need a different domain suffix, e.g. "et.internal." or "easytier.lan".
|
||||
tld_dns_zone_placeholder: e.g. et.net.
|
||||
enable_private_mode: Enable Private Mode
|
||||
enable_private_mode_help: |
|
||||
Enable private mode, nodes with different network names or passwords from this network are not allowed to perform handshake or relay through this node.
|
||||
|
||||
@@ -207,6 +207,7 @@ export function DEFAULT_NETWORK_CONFIG(): NetworkConfig {
|
||||
mtu: null,
|
||||
instance_recv_bps_limit: null,
|
||||
mapped_listeners: [],
|
||||
tld_dns_zone: 'et.net.',
|
||||
port_forwards: [],
|
||||
acl: emptyAcl(),
|
||||
}
|
||||
|
||||
@@ -416,6 +416,7 @@ describe('Config.vue network config projection', () => {
|
||||
expect(input(wrapper, '#no_tun').checked).toBe(true)
|
||||
|
||||
expect(input(wrapper, '#hostname').value).toBe('host-a')
|
||||
expect(input(wrapper, '#tld_dns_zone').value).toBe('et.net.')
|
||||
expect(input(wrapper, '#subnet-proxy').value).toBe('10.10.0.0/16,172.16.1.0/24')
|
||||
expect(input(wrapper, '#vpn_portal_wireguard_listen').value).toBe('0.0.0.0:22023')
|
||||
expect(input(wrapper, '#dev_name').value).toBe('tun-test')
|
||||
@@ -446,6 +447,7 @@ describe('Config.vue network config projection', () => {
|
||||
await wrapper.find('#no_tun').setValue(false)
|
||||
await wrapper.find('#disable_ipv6').setValue(false)
|
||||
await setInput(wrapper, '#hostname', 'host-edited')
|
||||
await setInput(wrapper, '#tld_dns_zone', 'nas.internal.')
|
||||
await setInput(wrapper, '#subnet-proxy', '10.7.0.0/16,172.17.0.0/16')
|
||||
await setInput(wrapper, '#vpn_portal_wireguard_listen', '[::]:23000')
|
||||
await setInput(wrapper, 'input[data-add-label="add_listener_url"]', 'tcp://0.0.0.0:13010')
|
||||
@@ -474,6 +476,7 @@ describe('Config.vue network config projection', () => {
|
||||
no_tun: false,
|
||||
disable_ipv6: false,
|
||||
hostname: 'host-edited',
|
||||
tld_dns_zone: 'nas.internal.',
|
||||
proxy_cidrs: ['10.7.0.0/16', '172.17.0.0/16'],
|
||||
vpn_portal_config: {
|
||||
wireguard_listen: '[::]:23000',
|
||||
@@ -509,6 +512,7 @@ describe('Config.vue network config projection', () => {
|
||||
network_secret: 'secret-edited',
|
||||
peer_urls: ['tcp://peer-x:11010', 'udp://peer-y:11010'],
|
||||
listener_urls: ['tcp://0.0.0.0:13010'],
|
||||
tld_dns_zone: 'nas.internal.',
|
||||
mtu: 1260,
|
||||
instance_recv_bps_limit: '9007199254740993',
|
||||
vpn_portal_config: {
|
||||
@@ -528,6 +532,9 @@ describe('Config.vue network config projection', () => {
|
||||
dst_port: 9090,
|
||||
}],
|
||||
})
|
||||
|
||||
await setInput(wrapper, '#tld_dns_zone', '')
|
||||
expect(toBackendNetworkConfig(curNetwork).tld_dns_zone).toBeUndefined()
|
||||
})
|
||||
|
||||
it('shows a flag the configuration does not state as the value the core resolves', async () => {
|
||||
|
||||
@@ -7,6 +7,39 @@ use easytier_core::connectivity::protocol::raw::TunnelDialer;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use tokio::{io::AsyncReadExt, net::TcpStream, sync::Notify, time::timeout};
|
||||
|
||||
#[derive(Debug)]
|
||||
struct FailingListener {
|
||||
inner: RuntimeRpcListener,
|
||||
attempts: Arc<std::sync::Mutex<Vec<tokio::time::Instant>>>,
|
||||
errors_remaining: usize,
|
||||
error_kind: std::io::ErrorKind,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl SocketListener for FailingListener {
|
||||
type Accepted = Box<dyn Tunnel>;
|
||||
|
||||
async fn listen(&mut self) -> anyhow::Result<()> {
|
||||
self.inner.listen().await
|
||||
}
|
||||
|
||||
async fn accept(&mut self) -> anyhow::Result<Self::Accepted> {
|
||||
self.attempts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(tokio::time::Instant::now());
|
||||
if self.errors_remaining > 0 {
|
||||
self.errors_remaining -= 1;
|
||||
return Err(std::io::Error::from(self.error_kind).into());
|
||||
}
|
||||
self.inner.accept().await
|
||||
}
|
||||
|
||||
fn local_url(&self) -> url::Url {
|
||||
self.inner.local_url()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TestListener {
|
||||
inner: RuntimeRpcListener,
|
||||
@@ -61,6 +94,66 @@ async fn manager() -> ClientManager {
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accept_errors_rebind_listener_with_fixed_delay() {
|
||||
for error_kind in [std::io::ErrorKind::OutOfMemory, std::io::ErrorKind::Other] {
|
||||
let mut manager = manager().await;
|
||||
let attempts = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let url = manager
|
||||
.add_listener(FailingListener {
|
||||
inner: runtime_rpc_listener("127.0.0.1:0".parse().unwrap()),
|
||||
attempts: attempts.clone(),
|
||||
errors_remaining: 2,
|
||||
error_kind,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_until(|| attempts.lock().unwrap().len() >= 3).await;
|
||||
assert!(manager.is_running());
|
||||
|
||||
let client = runtime_rpc_dialer(url).connect().await.unwrap();
|
||||
let _client = timeout(
|
||||
Duration::from_secs(1),
|
||||
web_security::upgrade_client_tunnel(client),
|
||||
)
|
||||
.await
|
||||
.expect("listener did not recover after accept errors")
|
||||
.unwrap();
|
||||
wait_until(|| manager.client_sessions.len() == 1).await;
|
||||
{
|
||||
let attempts = attempts.lock().unwrap();
|
||||
assert!(attempts.len() >= 3);
|
||||
for pair in attempts[..3].windows(2) {
|
||||
assert!(pair[1] - pair[0] >= Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
assert!(manager.is_running());
|
||||
drop(manager);
|
||||
wait_until(|| Arc::strong_count(&attempts) == 1).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manager_shutdown_cancels_accept_error_retries() {
|
||||
let mut manager = manager().await;
|
||||
let attempts = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let url = manager
|
||||
.add_listener(FailingListener {
|
||||
inner: runtime_rpc_listener("127.0.0.1:0".parse().unwrap()),
|
||||
attempts: attempts.clone(),
|
||||
errors_remaining: usize::MAX,
|
||||
error_kind: std::io::ErrorKind::Other,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_until(|| !attempts.lock().unwrap().is_empty()).await;
|
||||
assert!(manager.is_running());
|
||||
drop(manager);
|
||||
wait_until(|| Arc::strong_count(&attempts) == 1).await;
|
||||
let addr = url.socket_addrs(|| None).unwrap()[0];
|
||||
assert!(TcpStream::connect(addr).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slow_handshakes_do_not_block_other_clients() {
|
||||
let mut manager = manager().await;
|
||||
|
||||
@@ -199,7 +199,6 @@ impl ClientManager {
|
||||
self.listeners_cnt.fetch_add(1, Ordering::Relaxed);
|
||||
let sessions = self.client_sessions.clone();
|
||||
let storage = self.storage.weak_ref();
|
||||
let listeners_cnt = self.listeners_cnt.clone();
|
||||
let next_session_epoch = self.next_session_epoch.clone();
|
||||
let geoip_db = self.geoip_db.clone();
|
||||
let heartbeat_policy = self.heartbeat_policy;
|
||||
@@ -207,15 +206,15 @@ impl ClientManager {
|
||||
let webhook_config = self.webhook_config.clone();
|
||||
self.tasks.spawn(async move {
|
||||
let mut handshakes = JoinSet::new();
|
||||
'accept: loop {
|
||||
loop {
|
||||
// Some listeners include a WebSocket upgrade in accept(). Keep
|
||||
// that future alive while processing completed handshakes.
|
||||
let accepting = listener.accept();
|
||||
let accepting = easytier_core::socket::retry::accept_with_rebind(&mut listener);
|
||||
tokio::pin!(accepting);
|
||||
loop {
|
||||
let result = tokio::select! {
|
||||
accepted = &mut accepting, if handshakes.len() < MAX_PENDING_HANDSHAKES => {
|
||||
let Ok(tunnel) = accepted else { break 'accept };
|
||||
let tunnel = accepted;
|
||||
handshakes.spawn(web_security::accept_or_upgrade_server_tunnel(tunnel));
|
||||
break;
|
||||
}
|
||||
@@ -258,7 +257,6 @@ impl ClientManager {
|
||||
session.mark_route_ready();
|
||||
}
|
||||
}
|
||||
listeners_cnt.fetch_sub(1, Ordering::Relaxed);
|
||||
});
|
||||
|
||||
Ok(local_url)
|
||||
@@ -347,7 +345,11 @@ impl ClientManager {
|
||||
) && let Some(session) = self.get_session_by_machine_id(user_id, &machine_id)
|
||||
{
|
||||
session
|
||||
.notify_managed_runtime_state_changed(user_id, machine_id)
|
||||
.notify_managed_runtime_state_changed(
|
||||
user_id,
|
||||
machine_id,
|
||||
config_revision.as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(())
|
||||
@@ -392,13 +394,17 @@ impl ClientManager {
|
||||
machine_id,
|
||||
ManagedConfigPersistedChange {
|
||||
expected_revision: expected_config_revision,
|
||||
target_revision: config_revision,
|
||||
target_revision: config_revision.clone(),
|
||||
dirty_instance_ids,
|
||||
},
|
||||
);
|
||||
if changed && let Some(session) = self.get_session_by_machine_id(user_id, &machine_id) {
|
||||
session
|
||||
.notify_managed_runtime_state_changed(user_id, machine_id)
|
||||
.notify_managed_runtime_state_changed(
|
||||
user_id,
|
||||
machine_id,
|
||||
Some(&config_revision),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
@@ -416,7 +422,7 @@ impl ClientManager {
|
||||
&& let Some(session) = self.get_session_by_machine_id(user_id, &machine_id)
|
||||
{
|
||||
session
|
||||
.notify_managed_runtime_state_changed(user_id, machine_id)
|
||||
.notify_managed_runtime_state_changed(user_id, machine_id, None)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
@@ -651,6 +657,7 @@ mod tests {
|
||||
#[derive(Debug, Clone)]
|
||||
struct TestWebhookState {
|
||||
validate_responses: Arc<tokio::sync::Mutex<VecDeque<bool>>>,
|
||||
config_revision: Arc<tokio::sync::RwLock<String>>,
|
||||
validate_count: Arc<AtomicUsize>,
|
||||
block_second_validate: Arc<AtomicBool>,
|
||||
allow_second_validate: Arc<AtomicBool>,
|
||||
@@ -665,6 +672,7 @@ mod tests {
|
||||
validate_responses: Arc::new(tokio::sync::Mutex::new(
|
||||
validate_responses.into_iter().collect(),
|
||||
)),
|
||||
config_revision: Arc::new(tokio::sync::RwLock::new("rev-initial".to_owned())),
|
||||
validate_count: Arc::new(AtomicUsize::new(0)),
|
||||
block_second_validate: Arc::new(AtomicBool::new(false)),
|
||||
allow_second_validate: Arc::new(AtomicBool::new(true)),
|
||||
@@ -728,8 +736,8 @@ mod tests {
|
||||
|
||||
Json(json!({
|
||||
"valid": true,
|
||||
"binding_version": count,
|
||||
"config_revision": format!("validated-rev-{count}")
|
||||
"binding_version": 1,
|
||||
"config_revision": state.config_revision.read().await.clone()
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1078,6 +1086,7 @@ mod tests {
|
||||
assert_eq!(updated.disable_upnp, Some(true));
|
||||
assert_eq!(updated.disable_relay_data, Some(true));
|
||||
assert_eq!(updated.enable_magic_dns, Some(true));
|
||||
assert_eq!(updated.tld_dns_zone.as_deref(), Some("nas.internal."));
|
||||
assert_eq!(updated.enable_private_mode, Some(true));
|
||||
assert_eq!(updated.mtu, Some(1360));
|
||||
assert_eq!(
|
||||
@@ -1104,6 +1113,7 @@ mod tests {
|
||||
"relay_all_peer_rpc": false,
|
||||
"multi_thread": false,
|
||||
"disable_relay_data": false,
|
||||
"tld_dns_zone": "initial.internal.",
|
||||
"mtu": 1380
|
||||
})
|
||||
}
|
||||
@@ -1154,6 +1164,7 @@ mod tests {
|
||||
disable_upnp: Some(true),
|
||||
disable_relay_data: Some(true),
|
||||
enable_magic_dns: Some(true),
|
||||
tld_dns_zone: Some("nas.internal.".to_owned()),
|
||||
enable_private_mode: Some(true),
|
||||
mtu: Some(1360),
|
||||
data_compress_algo: Some(CompressionAlgoPb::Zstd as i32),
|
||||
@@ -1270,7 +1281,8 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn compact_runtime_preserves_unsupported_web_config_during_hot_patch() {
|
||||
let (webhook_config, webhook_server, _) = test_webhook_config().await;
|
||||
let (webhook_config, webhook_server, webhook_state) = test_webhook_config().await;
|
||||
*webhook_state.config_revision.write().await = "compact-initial".to_owned();
|
||||
let mut mgr = ClientManager::new(
|
||||
Db::memory_db().await,
|
||||
None,
|
||||
@@ -1307,6 +1319,7 @@ mod tests {
|
||||
})
|
||||
.await;
|
||||
|
||||
*webhook_state.config_revision.write().await = "compact-patched".to_owned();
|
||||
mgr.reconcile_managed_network_configs(
|
||||
user_id,
|
||||
machine_id,
|
||||
@@ -1334,9 +1347,284 @@ mod tests {
|
||||
webhook_server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn receiver_takeover_preserves_running_core_until_config_arrives() {
|
||||
for stale_config in [false, true] {
|
||||
let (old_webhook, old_server, _) = test_webhook_config().await;
|
||||
let mut old = ClientManager::new(
|
||||
Db::memory_db().await,
|
||||
None,
|
||||
HeartbeatPolicy::from_millis(1_000, 15_000).unwrap(),
|
||||
Arc::new(FeatureFlags::default()),
|
||||
old_webhook,
|
||||
);
|
||||
let old_addr = add_random_udp_listener(&mut old).await;
|
||||
let machine_id = uuid::Uuid::new_v4();
|
||||
let instance_id = uuid::Uuid::new_v4();
|
||||
let old_user = old
|
||||
.db()
|
||||
.auto_create_user(MANAGED_CONFIG_TOKEN)
|
||||
.await
|
||||
.unwrap()
|
||||
.id;
|
||||
let desired = initial_managed_network_config(instance_id);
|
||||
old.reconcile_managed_network_configs(
|
||||
old_user,
|
||||
machine_id,
|
||||
vec![managed_config(instance_id, desired.clone())],
|
||||
Some("rev-initial".to_owned()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let core = Arc::new(native_instance_manager());
|
||||
let old_client = start_web_client_for_test(old_addr, machine_id, core.clone()).await;
|
||||
wait_for_applied_revision(&old, old_user, machine_id, "rev-initial").await;
|
||||
let original = core.instance(instance_id).unwrap();
|
||||
drop(old_client);
|
||||
|
||||
let state = TestWebhookState::with_blocked_connected([true]);
|
||||
let (webhook, server, state) = test_webhook_config_with_state(state).await;
|
||||
let mut replacement = ClientManager::new(
|
||||
Db::memory_db().await,
|
||||
None,
|
||||
HeartbeatPolicy::from_millis(1_000, 15_000).unwrap(),
|
||||
Arc::new(FeatureFlags::default()),
|
||||
webhook,
|
||||
);
|
||||
let addr = add_random_udp_listener(&mut replacement).await;
|
||||
let user_id = replacement
|
||||
.db()
|
||||
.auto_create_user(MANAGED_CONFIG_TOKEN)
|
||||
.await
|
||||
.unwrap()
|
||||
.id;
|
||||
if stale_config {
|
||||
let obsolete_id = uuid::Uuid::new_v4();
|
||||
replacement
|
||||
.reconcile_managed_network_configs(
|
||||
user_id,
|
||||
machine_id,
|
||||
vec![managed_config(
|
||||
obsolete_id,
|
||||
initial_managed_network_config(obsolete_id),
|
||||
)],
|
||||
Some("outdated".to_owned()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let _client = start_web_client_for_test(addr, machine_id, core.clone()).await;
|
||||
wait_for_condition(
|
||||
|| async { state.connected_count() == 1 },
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await;
|
||||
let session = replacement
|
||||
.get_session_by_machine_id(user_id, &machine_id)
|
||||
.unwrap();
|
||||
|
||||
// Authentication and real heartbeats continue while publication is
|
||||
// withheld. Neither an empty DB nor an obsolete Exact Set owns the
|
||||
// already running Core instance.
|
||||
for _ in 0..3 {
|
||||
let before = session.get_heartbeat_req().await.unwrap().report_time;
|
||||
wait_for_condition(
|
||||
|| async { session.get_heartbeat_req().await.unwrap().report_time != before },
|
||||
Duration::from_secs(3),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(core.instance_ids(), vec![instance_id]);
|
||||
assert!(Arc::ptr_eq(&original, &core.instance(instance_id).unwrap()));
|
||||
assert!(session.applied_config_revision().await.is_none());
|
||||
}
|
||||
|
||||
state.allow_connected();
|
||||
// Registration must wake validation before its ten-heartbeat
|
||||
// periodic cadence, without repeatedly registering the same binding.
|
||||
wait_for_condition(
|
||||
|| async { state.validate_count() == 2 },
|
||||
Duration::from_secs(2),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(state.connected_count(), 1);
|
||||
assert!(Arc::ptr_eq(&original, &core.instance(instance_id).unwrap()));
|
||||
|
||||
replacement
|
||||
.reconcile_managed_network_configs(
|
||||
user_id,
|
||||
machine_id,
|
||||
vec![managed_config(instance_id, desired)],
|
||||
Some("rev-initial".to_owned()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_applied_revision(&replacement, user_id, machine_id, "rev-initial").await;
|
||||
assert_eq!(core.instance_ids(), vec![instance_id]);
|
||||
assert!(Arc::ptr_eq(&original, &core.instance(instance_id).unwrap()));
|
||||
|
||||
// A versioned authoritative empty set still removes web-owned
|
||||
// instances and keeps the authenticated management session alive.
|
||||
*state.config_revision.write().await = "empty".to_owned();
|
||||
replacement
|
||||
.reconcile_managed_network_configs(
|
||||
user_id,
|
||||
machine_id,
|
||||
Vec::new(),
|
||||
Some("empty".to_owned()),
|
||||
Some("rev-initial".to_owned()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_applied_revision(&replacement, user_id, machine_id, "empty").await;
|
||||
assert!(core.instance_ids().is_empty());
|
||||
assert!(
|
||||
replacement
|
||||
.get_session_by_machine_id(user_id, &machine_id)
|
||||
.is_some()
|
||||
);
|
||||
server.abort();
|
||||
old_server.abort();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
|
||||
#[ignore = "1000 real management connections; run explicitly with a raised file limit"]
|
||||
async fn receiver_takeover_wave_allows_an_independent_mount() {
|
||||
use futures::StreamExt;
|
||||
|
||||
let (old_webhook, old_server, _) = test_webhook_config().await;
|
||||
let mut old = ClientManager::new(
|
||||
Db::memory_db().await,
|
||||
None,
|
||||
HeartbeatPolicy::default(),
|
||||
Arc::new(FeatureFlags::default()),
|
||||
old_webhook,
|
||||
);
|
||||
let old_addr = add_random_udp_listener(&mut old).await;
|
||||
let (webhook, server, state) = test_webhook_config().await;
|
||||
let mut replacement = ClientManager::new(
|
||||
Db::memory_db().await,
|
||||
None,
|
||||
HeartbeatPolicy::default(),
|
||||
Arc::new(FeatureFlags::default()),
|
||||
webhook,
|
||||
);
|
||||
let addr = add_random_udp_listener(&mut replacement).await;
|
||||
// Provision the account once: password hashing is unrelated to a wave
|
||||
// of existing devices reconnecting to another receiver.
|
||||
let old_user = old
|
||||
.db()
|
||||
.auto_create_user(MANAGED_CONFIG_TOKEN)
|
||||
.await
|
||||
.unwrap()
|
||||
.id;
|
||||
let user_id = replacement
|
||||
.db()
|
||||
.auto_create_user(MANAGED_CONFIG_TOKEN)
|
||||
.await
|
||||
.unwrap()
|
||||
.id;
|
||||
let devices: Vec<_> = (0..1_000)
|
||||
.map(|_| {
|
||||
(
|
||||
uuid::Uuid::new_v4(),
|
||||
Arc::new(native_compact_instance_manager_with_runtime(
|
||||
tokio::runtime::Handle::current(),
|
||||
)),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let old_clients: Vec<_> = futures::stream::iter(&devices)
|
||||
.map(|(machine_id, core)| async {
|
||||
old.reconcile_managed_network_configs(
|
||||
old_user,
|
||||
*machine_id,
|
||||
Vec::new(),
|
||||
Some("rev-initial".to_owned()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let client = start_web_client_for_test(old_addr, *machine_id, core.clone()).await;
|
||||
wait_for_applied_revision(&old, old_user, *machine_id, "rev-initial").await;
|
||||
client
|
||||
})
|
||||
.buffer_unordered(64)
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
let mount_machine = uuid::Uuid::new_v4();
|
||||
let mount_core = Arc::new(native_compact_instance_manager_with_runtime(
|
||||
tokio::runtime::Handle::current(),
|
||||
));
|
||||
let _mount_client =
|
||||
start_web_client_for_test(addr, mount_machine, mount_core.clone()).await;
|
||||
wait_for_validated_user(&replacement, mount_machine).await;
|
||||
drop(old_clients);
|
||||
let started = tokio::time::Instant::now();
|
||||
let takeover = futures::stream::iter(&devices)
|
||||
.map(|(machine_id, core)| async {
|
||||
let client = start_web_client_for_test(addr, *machine_id, core.clone()).await;
|
||||
wait_for_validated_user(&replacement, *machine_id).await;
|
||||
replacement
|
||||
.reconcile_managed_network_configs(
|
||||
user_id,
|
||||
*machine_id,
|
||||
Vec::new(),
|
||||
Some("rev-initial".to_owned()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_applied_revision(&replacement, user_id, *machine_id, "rev-initial").await;
|
||||
client
|
||||
})
|
||||
.buffer_unordered(64)
|
||||
.collect::<Vec<_>>();
|
||||
let mount = async {
|
||||
wait_for_condition(
|
||||
|| async { state.connected_count() >= 11 },
|
||||
Duration::from_secs(12),
|
||||
)
|
||||
.await;
|
||||
let started = tokio::time::Instant::now();
|
||||
let instance_id = uuid::Uuid::new_v4();
|
||||
replacement
|
||||
.reconcile_managed_network_configs(
|
||||
user_id,
|
||||
mount_machine,
|
||||
vec![managed_config(
|
||||
instance_id,
|
||||
initial_managed_network_config(instance_id),
|
||||
)],
|
||||
Some("rev-initial".to_owned()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_applied_revision(&replacement, user_id, mount_machine, "rev-initial").await;
|
||||
assert_eq!(mount_core.instance_ids(), vec![instance_id]);
|
||||
started.elapsed()
|
||||
};
|
||||
let (clients, mount_elapsed) = tokio::join!(takeover, mount);
|
||||
assert_eq!(clients.len(), 1_000);
|
||||
assert_eq!(replacement.list_sessions().await.len(), 1_001);
|
||||
println!(
|
||||
"1000 receiver takeovers: {:?}; independent mount: {:?}; validations: {}",
|
||||
started.elapsed(),
|
||||
mount_elapsed,
|
||||
state.validate_count()
|
||||
);
|
||||
server.abort();
|
||||
old_server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn managed_web_config_revision_updates_running_core_config() {
|
||||
let (webhook_config, webhook_server, _) = test_webhook_config().await;
|
||||
let (webhook_config, webhook_server, webhook_state) = test_webhook_config().await;
|
||||
let mut mgr = ClientManager::new(
|
||||
Db::memory_db().await,
|
||||
None,
|
||||
@@ -1366,10 +1654,11 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
wait_for_runtime_config(&core_manager, instance_id, |config| {
|
||||
let initial = wait_for_runtime_config(&core_manager, instance_id, |config| {
|
||||
config.network_name.as_deref() == Some("managed-initial")
|
||||
})
|
||||
.await;
|
||||
assert_eq!(initial.tld_dns_zone.as_deref(), Some("initial.internal."));
|
||||
wait_for_applied_revision(&mgr, user_id, machine_id, "rev-initial").await;
|
||||
|
||||
// Runtime-only mutations do not change SQLite. Invalidate the Session
|
||||
@@ -1400,6 +1689,7 @@ mod tests {
|
||||
|
||||
// Online revision update: web-owned running config is fully overwritten
|
||||
// when non-hot-patch flags such as enable_kcp_proxy change.
|
||||
*webhook_state.config_revision.write().await = "rev-updated".to_owned();
|
||||
mgr.reconcile_managed_network_configs(
|
||||
user_id,
|
||||
machine_id,
|
||||
@@ -1454,6 +1744,7 @@ mod tests {
|
||||
.is_none()
|
||||
);
|
||||
|
||||
*webhook_state.config_revision.write().await = "rev-webhook-redelivery".to_owned();
|
||||
mgr.reconcile_managed_network_configs(
|
||||
user_id,
|
||||
machine_id,
|
||||
@@ -1484,6 +1775,7 @@ mod tests {
|
||||
Some("managed-redelivered")
|
||||
);
|
||||
assert_eq!(redelivered.enable_kcp_proxy, Some(true));
|
||||
assert_eq!(redelivered.tld_dns_zone, None);
|
||||
assert_eq!(redelivered.instance_recv_bps_limit, Some(654321));
|
||||
assert_eq!(
|
||||
core_manager.config_source(instance_id),
|
||||
@@ -1519,6 +1811,7 @@ mod tests {
|
||||
Some("managed-redelivered")
|
||||
);
|
||||
assert_eq!(replayed.enable_kcp_proxy, Some(true));
|
||||
assert_eq!(replayed.tld_dns_zone, None);
|
||||
assert_eq!(replayed.instance_recv_bps_limit, Some(654321));
|
||||
|
||||
webhook_server.abort();
|
||||
|
||||
@@ -730,6 +730,26 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn magic_dns_zone_change_requires_recreate() {
|
||||
let current = config_with_port_forwards(Vec::new());
|
||||
let mut desired = current.clone();
|
||||
desired.tld_dns_zone = Some("et.net.".to_owned());
|
||||
let action =
|
||||
prepare_web_source_runtime_reconcile_from_current(¤t, desired.clone(), false)
|
||||
.unwrap();
|
||||
assert!(matches!(action, RuntimeReconcileAction::Unchanged(_)));
|
||||
|
||||
desired.tld_dns_zone = Some("nas.internal.".to_owned());
|
||||
let action =
|
||||
prepare_web_source_runtime_reconcile_from_current(¤t, desired, false).unwrap();
|
||||
let RuntimeReconcileAction::Run { config, overwrite } = action else {
|
||||
panic!("changing the DNS zone must recreate the instance");
|
||||
};
|
||||
assert!(overwrite);
|
||||
assert_eq!(config.tld_dns_zone.as_deref(), Some("nas.internal."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vpn_portal_client_changes_produce_hot_patches() {
|
||||
let current = config_with_vpn_portal(
|
||||
|
||||
@@ -133,9 +133,13 @@ pub struct SessionData {
|
||||
|
||||
storage_token: Option<StorageToken>,
|
||||
binding_version: Option<u64>,
|
||||
// Authentication does not prove that this receiver has the desired Exact Set.
|
||||
// Keep that authority on the Session, not in runtime continuity shared with
|
||||
// an earlier connection.
|
||||
webhook_config_revision: Option<String>,
|
||||
managed_runtime: SharedManagedRuntimeState,
|
||||
direct_run_failed_instance_ids: HashSet<String>,
|
||||
notifier: broadcast::Sender<HeartbeatRequest>,
|
||||
notifier: broadcast::Sender<()>,
|
||||
req: Option<HeartbeatRequest>,
|
||||
location: Option<Location>,
|
||||
heartbeat_count: std::sync::atomic::AtomicU32,
|
||||
@@ -165,6 +169,7 @@ impl SessionData {
|
||||
client_url,
|
||||
storage_token: None,
|
||||
binding_version: None,
|
||||
webhook_config_revision: None,
|
||||
managed_runtime: Arc::new(Mutex::new(ManagedRuntimeState::default())),
|
||||
direct_run_failed_instance_ids: HashSet::new(),
|
||||
notifier: tx,
|
||||
@@ -185,10 +190,22 @@ impl SessionData {
|
||||
self.req.clone()
|
||||
}
|
||||
|
||||
pub fn heartbeat_waiter(&self) -> broadcast::Receiver<HeartbeatRequest> {
|
||||
pub fn heartbeat_waiter(&self) -> broadcast::Receiver<()> {
|
||||
self.notifier.subscribe()
|
||||
}
|
||||
|
||||
fn runtime_config_is_ready(&self, persisted_revision: Option<&str>) -> bool {
|
||||
if !self.webhook_config.is_enabled() {
|
||||
return true;
|
||||
}
|
||||
match self.webhook_config_revision.as_deref() {
|
||||
// Standalone webhook integrations can omit both managed fields.
|
||||
Some("") if self.binding_version == Some(0) => true,
|
||||
Some(revision) if !revision.is_empty() => persisted_revision == Some(revision),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn location(&self) -> Option<&Location> {
|
||||
self.location.as_ref()
|
||||
}
|
||||
@@ -302,6 +319,11 @@ async fn record_webhook_connected_binding_if_current(
|
||||
binding_version: u64,
|
||||
) -> Option<ConnectedBindingRecord> {
|
||||
let session_data = session_data.upgrade()?;
|
||||
let storage = Storage::try_from(session_data.read().await.storage.clone()).ok()?;
|
||||
let persisted_revision = storage
|
||||
.db()
|
||||
.get_managed_config_revision((storage_token.user_id, storage_token.machine_id))
|
||||
.await;
|
||||
let mut data = session_data.write().await;
|
||||
if !connection_state_matches(&data, storage_token, binding_version) {
|
||||
return Some(ConnectedBindingRecord::IdentityStale);
|
||||
@@ -313,7 +335,18 @@ async fn record_webhook_connected_binding_if_current(
|
||||
{
|
||||
return Some(ConnectedBindingRecord::OwnershipLost);
|
||||
}
|
||||
let newly_connected = data.webhook_connected_binding_version != Some(binding_version);
|
||||
data.webhook_connected_binding_version = Some(binding_version);
|
||||
if newly_connected
|
||||
&& data.webhook_config.is_enabled()
|
||||
&& !persisted_revision
|
||||
.as_ref()
|
||||
.is_ok_and(|revision| data.runtime_config_is_ready(revision.as_deref()))
|
||||
{
|
||||
// The connected ACK makes the receiver route publishable. Retry the
|
||||
// existing validation path now, rather than waiting ten heartbeats.
|
||||
SessionRpcService::mark_webhook_validation_state_changed_locked(&mut data).notify_one();
|
||||
}
|
||||
Some(ConnectedBindingRecord::Recorded)
|
||||
}
|
||||
|
||||
@@ -748,13 +781,13 @@ impl SessionRpcService {
|
||||
data.session_epoch,
|
||||
);
|
||||
}
|
||||
let runtime_notify = (authorized && data.storage_token.is_some())
|
||||
.then(|| (data.notifier.clone(), runtime_req));
|
||||
let runtime_notify =
|
||||
(authorized && data.storage_token.is_some()).then(|| data.notifier.clone());
|
||||
(notify, runtime_notify)
|
||||
};
|
||||
|
||||
if let Some((notifier, runtime_req)) = runtime_notify {
|
||||
let _ = notifier.send(runtime_req);
|
||||
if let Some(notifier) = runtime_notify {
|
||||
let _ = notifier.send(());
|
||||
}
|
||||
if let Some(notify) = notify {
|
||||
notify.notify_one();
|
||||
@@ -859,7 +892,7 @@ impl SessionRpcService {
|
||||
|
||||
let report_time = Self::heartbeat_report_timestamp(&runtime_req);
|
||||
storage.update_session_client(storage_token, report_time, true, session_epoch);
|
||||
let _ = notifier.send(runtime_req);
|
||||
let _ = notifier.send(());
|
||||
if let Some(notify) = validation_notify {
|
||||
notify.notify_one();
|
||||
}
|
||||
@@ -1048,9 +1081,10 @@ impl Session {
|
||||
&self,
|
||||
user_id: i32,
|
||||
machine_id: uuid::Uuid,
|
||||
published_revision: Option<&str>,
|
||||
) {
|
||||
let notify = {
|
||||
let data = self.data.read().await;
|
||||
let mut data = self.data.write().await;
|
||||
if !data.auth_state.is_authorized() {
|
||||
return;
|
||||
}
|
||||
@@ -1061,10 +1095,21 @@ impl Session {
|
||||
{
|
||||
return;
|
||||
}
|
||||
data.req.clone().map(|req| (data.notifier.clone(), req))
|
||||
if let Some(revision) = published_revision
|
||||
&& data.webhook_config.is_enabled()
|
||||
&& data.webhook_config_revision.as_deref() != Some(revision)
|
||||
{
|
||||
// Only validation establishes the desired revision. A delayed
|
||||
// publication callback must not replace newer authority, even
|
||||
// when its revision is still the receiver's persisted value.
|
||||
// Fence validation already in flight and request current authority.
|
||||
SessionRpcService::mark_webhook_validation_state_changed_locked(&mut data)
|
||||
.notify_one();
|
||||
}
|
||||
data.req.as_ref().map(|_| data.notifier.clone())
|
||||
};
|
||||
if let Some((notifier, req)) = notify {
|
||||
let _ = notifier.send(req);
|
||||
if let Some(notifier) = notify {
|
||||
let _ = notifier.send(());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1082,10 +1127,10 @@ impl Session {
|
||||
runtime.runtime_config_epoch = runtime.runtime_config_epoch.wrapping_add(1);
|
||||
runtime.runtime_config_cache_epoch = runtime.runtime_config_cache_epoch.wrapping_add(1);
|
||||
drop(runtime);
|
||||
data.req.clone().map(|req| (data.notifier.clone(), req))
|
||||
data.req.as_ref().map(|_| data.notifier.clone())
|
||||
};
|
||||
if let Some((notifier, req)) = notify {
|
||||
let _ = notifier.send(req);
|
||||
if let Some(notifier) = notify {
|
||||
let _ = notifier.send(());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1805,7 +1850,7 @@ mod tests {
|
||||
assert_eq!(data.binding_version, Some(1));
|
||||
assert_eq!(data.webhook_connected_binding_version, Some(1));
|
||||
drop(data);
|
||||
assert_eq!(heartbeat_waiter.recv().await.unwrap().user_token, "token");
|
||||
heartbeat_waiter.recv().await.unwrap();
|
||||
assert!(
|
||||
storage
|
||||
.db()
|
||||
|
||||
@@ -24,11 +24,12 @@ use crate::client_manager::{
|
||||
};
|
||||
|
||||
async fn recv_latest_heartbeat(
|
||||
heartbeat_waiter: &mut broadcast::Receiver<HeartbeatRequest>,
|
||||
session_data: &std::sync::Weak<RwLock<SessionData>>,
|
||||
heartbeat_waiter: &mut broadcast::Receiver<()>,
|
||||
) -> Option<HeartbeatRequest> {
|
||||
let mut req = loop {
|
||||
loop {
|
||||
match heartbeat_waiter.recv().await {
|
||||
Ok(req) => break req,
|
||||
Ok(()) => break,
|
||||
Err(broadcast::error::RecvError::Lagged(skipped)) => {
|
||||
tracing::warn!(
|
||||
skipped,
|
||||
@@ -40,21 +41,23 @@ async fn recv_latest_heartbeat(
|
||||
return None;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Drop any heartbeat backlog accumulated while the previous reconcile
|
||||
// round was doing DB/RPC IO. The newest heartbeat has the freshest
|
||||
// runtime instance list, which is all this task needs.
|
||||
loop {
|
||||
match heartbeat_waiter.try_recv() {
|
||||
Ok(next_req) => req = next_req,
|
||||
Ok(()) => {}
|
||||
Err(broadcast::error::TryRecvError::Empty) => break,
|
||||
Err(broadcast::error::TryRecvError::Lagged(_)) => continue,
|
||||
Err(broadcast::error::TryRecvError::Closed) => return None,
|
||||
}
|
||||
}
|
||||
|
||||
Some(req)
|
||||
// Notifications can be delayed by validation or publication IO. Read the
|
||||
// current heartbeat instead of replaying the snapshot taken before that IO.
|
||||
session_data.upgrade()?.read().await.req()
|
||||
}
|
||||
|
||||
struct SessionClients {
|
||||
@@ -65,7 +68,7 @@ struct SessionClients {
|
||||
|
||||
pub(super) async fn reconcile_network_configs_on_heartbeat(
|
||||
session_data: std::sync::Weak<RwLock<SessionData>>,
|
||||
mut heartbeat_waiter: broadcast::Receiver<HeartbeatRequest>,
|
||||
mut heartbeat_waiter: broadcast::Receiver<()>,
|
||||
storage: WeakRefStorage,
|
||||
rpc_client: SessionRpcClient,
|
||||
config_client: SessionConfigClient,
|
||||
@@ -78,7 +81,7 @@ pub(super) async fn reconcile_network_configs_on_heartbeat(
|
||||
};
|
||||
let mut cache = ReconcileCache::default();
|
||||
loop {
|
||||
let Some(req) = recv_latest_heartbeat(&mut heartbeat_waiter).await else {
|
||||
let Some(req) = recv_latest_heartbeat(&session_data, &mut heartbeat_waiter).await else {
|
||||
return;
|
||||
};
|
||||
let Some(storage) = storage.upgrade() else {
|
||||
@@ -404,6 +407,15 @@ async fn prepare_reconcile_round(
|
||||
RoundStatus::Skip => return RoundStatus::Skip,
|
||||
RoundStatus::Stop => return RoundStatus::Stop,
|
||||
};
|
||||
{
|
||||
let Some(data) = session_data.upgrade() else {
|
||||
return RoundStatus::Stop;
|
||||
};
|
||||
let data = data.read().await;
|
||||
if !data.runtime_config_is_ready(target_config_revision.as_deref()) {
|
||||
return RoundStatus::Skip;
|
||||
}
|
||||
}
|
||||
let should_apply_runtime_revision =
|
||||
target_config_revision.is_some() && target_config_revision != applied_config_revision;
|
||||
let mut scope = if should_apply_runtime_revision {
|
||||
@@ -886,7 +898,9 @@ async fn begin_managed_runtime_mutation(
|
||||
return false;
|
||||
};
|
||||
let data = data.write().await;
|
||||
if !SessionRpcService::runtime_heartbeat_is_current_locked(&data, &round.req) {
|
||||
if !SessionRpcService::runtime_heartbeat_is_current_locked(&data, &round.req)
|
||||
|| !data.runtime_config_is_ready(round.target_config_revision.as_deref())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let managed_runtime = data.managed_runtime.clone();
|
||||
@@ -1265,7 +1279,9 @@ async fn mark_config_revision_applied_if_current(
|
||||
};
|
||||
let notify = {
|
||||
let mut data = data.write().await;
|
||||
if !SessionRpcService::runtime_heartbeat_is_current_locked(&data, &round.req) {
|
||||
if !SessionRpcService::runtime_heartbeat_is_current_locked(&data, &round.req)
|
||||
|| !data.runtime_config_is_ready(round.target_config_revision.as_deref())
|
||||
{
|
||||
return RoundStatus::Ready(());
|
||||
}
|
||||
record_applied_config_revision(
|
||||
@@ -1319,6 +1335,286 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
async fn takeover_session(
|
||||
persisted_revision: Option<&str>,
|
||||
) -> (
|
||||
crate::client_manager::storage::Storage,
|
||||
std::sync::Arc<RwLock<SessionData>>,
|
||||
ReconcileRound,
|
||||
) {
|
||||
use super::super::{SessionAuthState, webhook_validation};
|
||||
use crate::client_manager::storage::{Storage, StorageToken};
|
||||
|
||||
let storage = Storage::new(crate::db::Db::memory_db().await);
|
||||
let user_id = storage.db().auto_create_user("token").await.unwrap().id;
|
||||
let machine_id = uuid::Uuid::new_v4();
|
||||
if let Some(revision) = persisted_revision {
|
||||
storage
|
||||
.db()
|
||||
.set_managed_config_revision((user_id, machine_id), revision)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let req = HeartbeatRequest {
|
||||
user_token: "token".to_owned(),
|
||||
machine_id: Some(machine_id.into()),
|
||||
running_network_instances: vec![uuid::Uuid::new_v4().into()],
|
||||
..Default::default()
|
||||
};
|
||||
let client_url = "tcp://127.0.0.1:1001".parse().unwrap();
|
||||
let webhook = std::sync::Arc::new(crate::webhook::WebhookConfig::new(
|
||||
Some("http://127.0.0.1:1".to_owned()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
));
|
||||
let mut data = SessionData::new(
|
||||
storage.weak_ref(),
|
||||
client_url,
|
||||
None,
|
||||
std::sync::Arc::new(crate::FeatureFlags::default()),
|
||||
webhook.clone(),
|
||||
);
|
||||
data.req = Some(req.clone());
|
||||
data.auth_state = SessionAuthState::Authorized;
|
||||
data.binding_version = Some(1);
|
||||
data.webhook_connected_binding_version = Some(1);
|
||||
data.storage_token = Some(StorageToken {
|
||||
token: req.user_token.clone(),
|
||||
client_url: data.client_url.clone(),
|
||||
machine_id,
|
||||
user_id,
|
||||
});
|
||||
data.managed_runtime = storage.bind_managed_runtime_state(user_id, machine_id, None, 0);
|
||||
data.managed_runtime().applied_config_revision = persisted_revision.map(str::to_owned);
|
||||
let session_data = std::sync::Arc::new(RwLock::new(data));
|
||||
let client_url = session_data.read().await.client_url.clone();
|
||||
webhook_validation::apply_success(
|
||||
&std::sync::Arc::downgrade(&session_data),
|
||||
webhook_validation::WebhookValidationInput {
|
||||
storage: storage.clone(),
|
||||
webhook_config: webhook,
|
||||
client_url,
|
||||
applied_config_revision: persisted_revision.map(str::to_owned),
|
||||
applied_config_revision_known: false,
|
||||
failed_instance_ids: Vec::new(),
|
||||
req: req.clone(),
|
||||
machine_id,
|
||||
},
|
||||
webhook_validation::WebhookHeartbeatValidation {
|
||||
config_revision: "current".to_owned(),
|
||||
binding_version: 1,
|
||||
},
|
||||
user_id,
|
||||
0,
|
||||
)
|
||||
.await;
|
||||
let mut round = round_with_revision_state(persisted_revision, false);
|
||||
round.req = req;
|
||||
round.machine_id = machine_id;
|
||||
round.user_id = user_id;
|
||||
round.runtime_config_epoch = session_data
|
||||
.read()
|
||||
.await
|
||||
.managed_runtime()
|
||||
.runtime_config_epoch;
|
||||
(storage, session_data, round)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn takeover_skips_missing_and_outdated_receiver_config() {
|
||||
for revision in [None, Some("outdated")] {
|
||||
let (storage, data, round) = takeover_session(revision).await;
|
||||
let rpc = easytier::proto::rpc::bidirect::BidirectRpcManager::new();
|
||||
let mut client: SessionRpcClient = rpc.rpc_client().scoped_client::<
|
||||
easytier::proto::api::manage::WebClientServiceClientFactory<BaseController>,
|
||||
>(1, 1, String::new());
|
||||
|
||||
assert!(matches!(
|
||||
prepare_reconcile_round(
|
||||
&std::sync::Arc::downgrade(&data),
|
||||
&storage.weak_ref().upgrade().unwrap(),
|
||||
&mut client,
|
||||
round.req,
|
||||
)
|
||||
.await,
|
||||
RoundStatus::Skip
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn takeover_fences_mutations_and_applied_reports_from_unready_rounds() {
|
||||
for revision in [None, Some("outdated")] {
|
||||
let (storage, data, mut round) = takeover_session(revision).await;
|
||||
let weak = std::sync::Arc::downgrade(&data);
|
||||
let mut mutation = RuntimeMutationFence::default();
|
||||
assert!(!begin_managed_runtime_mutation(&weak, &round, &mut mutation).await);
|
||||
assert!(!mutation.started);
|
||||
|
||||
round.should_apply_runtime_revision = true;
|
||||
mark_config_revision_applied_if_current(
|
||||
&weak,
|
||||
&storage.weak_ref().upgrade().unwrap(),
|
||||
&round,
|
||||
&ReconcileOutcome::default(),
|
||||
&mutation,
|
||||
)
|
||||
.await;
|
||||
let data = data.read().await;
|
||||
let runtime = data.managed_runtime();
|
||||
assert!(!runtime.applied_config_revision_known);
|
||||
assert_eq!(runtime.applied_config_revision.as_deref(), revision);
|
||||
}
|
||||
}
|
||||
|
||||
async fn validate_takeover_session(
|
||||
storage: &crate::client_manager::storage::Storage,
|
||||
data: &std::sync::Arc<RwLock<SessionData>>,
|
||||
round: &ReconcileRound,
|
||||
revision: &str,
|
||||
epoch: u64,
|
||||
) {
|
||||
use super::super::webhook_validation;
|
||||
|
||||
let (webhook_config, client_url) = {
|
||||
let data = data.read().await;
|
||||
(data.webhook_config.clone(), data.client_url.clone())
|
||||
};
|
||||
webhook_validation::apply_success(
|
||||
&std::sync::Arc::downgrade(data),
|
||||
webhook_validation::WebhookValidationInput {
|
||||
storage: storage.clone(),
|
||||
webhook_config,
|
||||
client_url,
|
||||
applied_config_revision: None,
|
||||
applied_config_revision_known: false,
|
||||
failed_instance_ids: Vec::new(),
|
||||
req: round.req.clone(),
|
||||
machine_id: round.machine_id,
|
||||
},
|
||||
webhook_validation::WebhookHeartbeatValidation {
|
||||
config_revision: revision.to_owned(),
|
||||
binding_version: 1,
|
||||
},
|
||||
round.user_id,
|
||||
epoch,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn takeover_new_publication_fences_late_validation_and_publication_callbacks() {
|
||||
use super::super::{HeartbeatPolicy, Session};
|
||||
use std::sync::Arc;
|
||||
|
||||
let (storage, data, round) = takeover_session(Some("current")).await;
|
||||
let mut session = Session::new(
|
||||
storage.weak_ref(),
|
||||
"tcp://127.0.0.1:1001".parse().unwrap(),
|
||||
None,
|
||||
HeartbeatPolicy::default(),
|
||||
Arc::new(crate::FeatureFlags::default()),
|
||||
data.read().await.webhook_config.clone(),
|
||||
0,
|
||||
);
|
||||
session.data = data.clone();
|
||||
let old_epoch = data.read().await.webhook_validation_change_epoch;
|
||||
storage
|
||||
.db()
|
||||
.set_managed_config_revision((round.user_id, round.machine_id), "newer")
|
||||
.await
|
||||
.unwrap();
|
||||
storage.record_full_managed_config_change(round.user_id, round.machine_id, Some("newer"));
|
||||
session
|
||||
.notify_managed_runtime_state_changed(round.user_id, round.machine_id, Some("newer"))
|
||||
.await;
|
||||
|
||||
validate_takeover_session(&storage, &data, &round, "current", old_epoch).await;
|
||||
session
|
||||
.notify_managed_runtime_state_changed(round.user_id, round.machine_id, Some("current"))
|
||||
.await;
|
||||
let current_epoch = {
|
||||
let data = data.read().await;
|
||||
assert_eq!(data.webhook_config_revision.as_deref(), Some("current"));
|
||||
assert!(!data.runtime_config_is_ready(Some("newer")));
|
||||
assert!(data.webhook_validation_dirty);
|
||||
data.webhook_validation_change_epoch
|
||||
};
|
||||
assert!(
|
||||
!begin_managed_runtime_mutation(
|
||||
&Arc::downgrade(&data),
|
||||
&round,
|
||||
&mut RuntimeMutationFence::default()
|
||||
)
|
||||
.await
|
||||
);
|
||||
|
||||
validate_takeover_session(&storage, &data, &round, "newer", current_epoch).await;
|
||||
assert!(data.read().await.runtime_config_is_ready(Some("newer")));
|
||||
|
||||
// A newer Console revision may be observed after a publication commits
|
||||
// but before its callback runs. SQLite still containing that publication
|
||||
// does not allow the late callback to replace the newer authority.
|
||||
validate_takeover_session(&storage, &data, &round, "newest", current_epoch).await;
|
||||
session
|
||||
.notify_managed_runtime_state_changed(round.user_id, round.machine_id, Some("newer"))
|
||||
.await;
|
||||
let data = data.read().await;
|
||||
assert_eq!(data.webhook_config_revision.as_deref(), Some("newest"));
|
||||
assert!(!data.runtime_config_is_ready(Some("newer")));
|
||||
assert!(data.webhook_validation_dirty);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unchanged_validation_does_not_replay_an_old_heartbeat() {
|
||||
let (storage, data, round) = takeover_session(Some("current")).await;
|
||||
let mut notifications = data.read().await.heartbeat_waiter();
|
||||
let epoch = data.read().await.webhook_validation_change_epoch;
|
||||
validate_takeover_session(&storage, &data, &round, "current", epoch).await;
|
||||
assert!(matches!(
|
||||
notifications.try_recv(),
|
||||
Err(broadcast::error::TryRecvError::Empty)
|
||||
));
|
||||
|
||||
let newest_instance = uuid::Uuid::new_v4();
|
||||
{
|
||||
let mut data = data.write().await;
|
||||
data.notifier.send(()).unwrap();
|
||||
data.req.as_mut().unwrap().running_network_instances = vec![newest_instance.into()];
|
||||
}
|
||||
let req = recv_latest_heartbeat(&std::sync::Arc::downgrade(&data), &mut notifications)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(req.running_network_instances, vec![newest_instance.into()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authoritative_empty_config_remains_ready_but_missing_revision_does_not() {
|
||||
let (storage, data, round) = takeover_session(Some("current")).await;
|
||||
let mut mutation = RuntimeMutationFence::default();
|
||||
assert!(round.local_configs.is_empty());
|
||||
assert!(
|
||||
begin_managed_runtime_mutation(
|
||||
&std::sync::Arc::downgrade(&data),
|
||||
&round,
|
||||
&mut mutation
|
||||
)
|
||||
.await
|
||||
);
|
||||
let epoch = data.read().await.webhook_validation_change_epoch;
|
||||
validate_takeover_session(&storage, &data, &round, "", epoch).await;
|
||||
let mut data = data.write().await;
|
||||
assert!(!data.runtime_config_is_ready(None));
|
||||
assert!(!data.runtime_config_is_ready(Some("current")));
|
||||
data.binding_version = Some(0);
|
||||
assert!(
|
||||
data.runtime_config_is_ready(None),
|
||||
"legacy webhook without managed fields"
|
||||
);
|
||||
}
|
||||
|
||||
fn config_with_port_forwards(port_forwards: Vec<PortForwardConfig>) -> NetworkConfig {
|
||||
NetworkConfig {
|
||||
instance_id: Some("11111111-1111-1111-1111-111111111111".to_string()),
|
||||
|
||||
@@ -393,7 +393,7 @@ pub(super) async fn apply_success(
|
||||
validation_change_epoch: u64,
|
||||
) {
|
||||
let WebhookHeartbeatValidation {
|
||||
config_revision: _,
|
||||
config_revision,
|
||||
binding_version,
|
||||
} = validation;
|
||||
|
||||
@@ -408,6 +408,7 @@ pub(super) async fn apply_success(
|
||||
validation_notify,
|
||||
runtime_req,
|
||||
session_epoch,
|
||||
notify_runtime,
|
||||
) = {
|
||||
let mut data = session_data.write().await;
|
||||
let Some(runtime_req) = data.req.clone() else {
|
||||
@@ -436,6 +437,9 @@ pub(super) async fn apply_success(
|
||||
return;
|
||||
}
|
||||
|
||||
let notify_runtime = !data.auth_state.is_authorized()
|
||||
|| data.webhook_config_revision.as_deref() != Some(config_revision.as_str());
|
||||
let runtime_changed = notify_runtime || data.binding_version != Some(binding_version);
|
||||
let previous_connected_binding_version = data.webhook_connected_binding_version;
|
||||
let client_url = data.client_url.clone();
|
||||
let is_new_storage_token = data.storage_token.is_none();
|
||||
@@ -461,6 +465,11 @@ pub(super) async fn apply_success(
|
||||
let storage_token = storage_token.clone();
|
||||
data.auth_state = SessionAuthState::Authorized;
|
||||
data.binding_version = Some(binding_version);
|
||||
data.webhook_config_revision = Some(config_revision);
|
||||
if runtime_changed {
|
||||
let mut runtime = data.managed_runtime();
|
||||
runtime.runtime_config_epoch = runtime.runtime_config_epoch.wrapping_add(1);
|
||||
}
|
||||
if is_new_storage_token {
|
||||
tracing::info!(
|
||||
machine_id = %input.machine_id,
|
||||
@@ -520,6 +529,7 @@ pub(super) async fn apply_success(
|
||||
validation_notify,
|
||||
runtime_req,
|
||||
data.session_epoch,
|
||||
notify_runtime,
|
||||
)
|
||||
};
|
||||
|
||||
@@ -541,7 +551,9 @@ pub(super) async fn apply_success(
|
||||
.await;
|
||||
}
|
||||
|
||||
let _ = notifier.send(runtime_req);
|
||||
if notify_runtime {
|
||||
let _ = notifier.send(());
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_webhook_connection_transition(
|
||||
@@ -563,6 +575,87 @@ async fn wait_webhook_connection_transition(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn unchanged_validation_does_not_repeat_runtime_notifications() {
|
||||
let storage = Storage::new(crate::db::Db::memory_db().await);
|
||||
let user_id = storage.db().auto_create_user("token").await.unwrap().id;
|
||||
let machine_id = uuid::Uuid::new_v4();
|
||||
let session_data = validation_session(machine_id).await;
|
||||
{
|
||||
let mut data = session_data.write().await;
|
||||
data.storage = storage.weak_ref();
|
||||
data.auth_state = SessionAuthState::Init;
|
||||
}
|
||||
let mut notifications = session_data.read().await.heartbeat_waiter();
|
||||
for attempt in 0..100 {
|
||||
let (input, epoch) = {
|
||||
let data = session_data.read().await;
|
||||
(
|
||||
WebhookValidationInput {
|
||||
storage: storage.clone(),
|
||||
webhook_config: data.webhook_config.clone(),
|
||||
client_url: data.client_url.clone(),
|
||||
applied_config_revision: (attempt > 0).then(|| "current".to_owned()),
|
||||
applied_config_revision_known: attempt > 0,
|
||||
failed_instance_ids: Vec::new(),
|
||||
req: data.req.clone().unwrap(),
|
||||
machine_id,
|
||||
},
|
||||
data.webhook_validation_change_epoch,
|
||||
)
|
||||
};
|
||||
apply_success(
|
||||
&Arc::downgrade(&session_data),
|
||||
input,
|
||||
WebhookHeartbeatValidation {
|
||||
config_revision: "current".to_owned(),
|
||||
binding_version: if attempt == 99 { 2 } else { 1 },
|
||||
},
|
||||
user_id,
|
||||
epoch,
|
||||
)
|
||||
.await;
|
||||
if attempt == 0 {
|
||||
notifications
|
||||
.try_recv()
|
||||
.expect("initial authentication wakes runtime");
|
||||
let data = session_data.read().await;
|
||||
let mut runtime = data.managed_runtime();
|
||||
runtime.applied_config_revision = Some("current".to_owned());
|
||||
runtime.applied_config_revision_known = true;
|
||||
} else {
|
||||
assert!(matches!(
|
||||
notifications.try_recv(),
|
||||
Err(tokio::sync::broadcast::error::TryRecvError::Empty)
|
||||
));
|
||||
}
|
||||
}
|
||||
// A fresh heartbeat still wakes the reconciler after repeated success.
|
||||
let instance_id = uuid::Uuid::new_v4();
|
||||
let mut fresh = session_data.read().await.req.clone().unwrap();
|
||||
fresh.running_network_instances = vec![instance_id.into()];
|
||||
SessionRpcService {
|
||||
data: session_data.clone(),
|
||||
heartbeat_policy: super::super::HeartbeatPolicy::default(),
|
||||
}
|
||||
.handle_heartbeat(fresh)
|
||||
.await
|
||||
.unwrap();
|
||||
notifications
|
||||
.try_recv()
|
||||
.expect("fresh heartbeat wakes runtime");
|
||||
assert_eq!(
|
||||
session_data
|
||||
.read()
|
||||
.await
|
||||
.req
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.running_network_instances,
|
||||
vec![instance_id.into()]
|
||||
);
|
||||
}
|
||||
|
||||
async fn validation_session(machine_id: uuid::Uuid) -> Arc<RwLock<SessionData>> {
|
||||
let storage = Storage::new(crate::db::Db::memory_db().await);
|
||||
let mut data = SessionData::new(
|
||||
|
||||
+3
-1
@@ -113,7 +113,7 @@ rcgen = { version = "0.14.10", optional = true }
|
||||
tokio-rustls = { workspace = true, optional = true }
|
||||
|
||||
# for tap device
|
||||
tun = { package = "tun-easytier", git = "https://github.com/EasyTier/rust-tun", rev = "5ba6be073e5b486b68c12f80fac5cfb50601f024", features = [
|
||||
tun = { package = "tun-easytier", git = "https://github.com/EasyTier/rust-tun", rev = "ac0c674c900dbdcf0b3fe9f5f6134c11579d1f7c", features = [
|
||||
"async",
|
||||
], optional = true }
|
||||
# for net ns
|
||||
@@ -426,6 +426,8 @@ endpoint-discovery = [
|
||||
"dep:rustls",
|
||||
"easytier-core/endpoint-discovery",
|
||||
]
|
||||
# Trust policy is opt-in, including when building with full features.
|
||||
system-certificates = ["endpoint-discovery", "easytier-core/system-certificates"]
|
||||
dhcp-ipv4 = ["easytier-core/dhcp-ipv4"]
|
||||
dhcp-ipv6 = ["easytier-core/dhcp-ipv6"]
|
||||
public-ipv6-provider = ["linux-netlink", "easytier-core/public-ipv6-provider"]
|
||||
|
||||
@@ -127,11 +127,14 @@ core_clap:
|
||||
en: "encryption algorithm to use, supported: '', 'xor', 'chacha20', 'aes-gcm', 'aes-gcm-256', 'openssl-aes128-gcm', 'openssl-aes256-gcm', 'openssl-chacha20'. Empty string means default (aes-gcm)"
|
||||
zh-CN: "要使用的加密算法,支持:''(默认aes-gcm)、'xor'、'chacha20'、'aes-gcm'、'aes-gcm-256'、'openssl-aes128-gcm'、'openssl-aes256-gcm'、'openssl-chacha20'"
|
||||
multi_thread:
|
||||
en: "use multi-thread runtime, default is single-thread"
|
||||
zh-CN: "使用多线程运行时,默认为单线程"
|
||||
en: "use a multi-thread runtime for each instance, default is true; ignored with --shared-runtime"
|
||||
zh-CN: "为每个实例使用多线程运行时,默认开启;启用 --shared-runtime 时忽略此项"
|
||||
multi_thread_count:
|
||||
en: "the number of threads to use, default is 2, only effective when multi-thread is enabled, must be greater than 2"
|
||||
zh-CN: "使用的线程数,默认为2,仅在多线程模式下有效。取值必须大于2"
|
||||
en: "runtime worker count: default/minimum 2 per instance; with --shared-runtime, defaults to available CPUs with a minimum of 1"
|
||||
zh-CN: "运行时工作线程数:独占多线程模式默认及最小值为 2;启用 --shared-runtime 时默认为可用 CPU 数,最小值为 1"
|
||||
shared_runtime:
|
||||
en: "share one multi-thread runtime across all instances; --multi-thread-count sets its worker count independently of instance configuration files"
|
||||
zh-CN: "所有实例共用一个多线程运行时;通过 --multi-thread-count 设置工作线程数,不受实例配置文件影响"
|
||||
disable_ipv6:
|
||||
en: "do not use ipv6"
|
||||
zh-CN: "不使用IPv6"
|
||||
|
||||
@@ -64,6 +64,7 @@ pub fn disable_connection_reset<S: AsRawSocket>(socket: &S) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Configuration operations must see newly created or recreated adapters immediately.
|
||||
pub fn find_interface_index(iface_name: &str) -> io::Result<u32> {
|
||||
let ifaces = network_interface::NetworkInterface::show().map_err(|e| {
|
||||
io::Error::new(
|
||||
@@ -81,8 +82,22 @@ pub fn find_interface_index(iface_name: &str) -> io::Result<u32> {
|
||||
))
|
||||
}
|
||||
|
||||
fn find_interface_index_for_bind(iface_name: &str) -> io::Result<u32> {
|
||||
let ifaces = crate::common::network::get_interfaces_cached().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
format!("Failed to get interfaces for {}", iface_name),
|
||||
)
|
||||
})?;
|
||||
ifaces
|
||||
.iter()
|
||||
.find(|iface| iface.name == iface_name)
|
||||
.map(|iface| iface.index)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, iface_name.to_string()))
|
||||
}
|
||||
|
||||
pub fn set_ip_unicast_if(socket: SOCKET, addr: &SocketAddr, iface: &str) -> io::Result<()> {
|
||||
let if_index = find_interface_index(iface)?;
|
||||
let if_index = find_interface_index_for_bind(iface)?;
|
||||
|
||||
unsafe {
|
||||
// https://docs.microsoft.com/en-us/windows/win32/winsock/ipproto-ip-socket-options
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
#[cfg(not(mobile))]
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
use std::sync::{Arc, RwLock};
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use network_interface::{NetworkInterface, NetworkInterfaceConfig};
|
||||
#[cfg(all(target_os = "macos", not(feature = "macos-ne")))]
|
||||
@@ -303,6 +307,48 @@ pub(crate) async fn collect_interfaces(net_ns: NetNS, filter: bool) -> Vec<Netwo
|
||||
}
|
||||
}
|
||||
|
||||
/// The Windows bind path resolves an interface name and then its index
|
||||
/// through full `NetworkInterface::show()` enumerations for every bind
|
||||
/// candidate. With 10+ adapters (VMware / Hyper-V / APIPA leftovers are
|
||||
/// common), one connect round turns into O(candidates) synchronous
|
||||
/// enumerations on the async worker. Interfaces rarely change within a
|
||||
/// connect round, so snapshots are reused for a short TTL.
|
||||
/// Only use this cache for socket binding; interface configuration needs
|
||||
/// fresh enumeration to observe adapter creation and replacement immediately.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn get_interfaces_cached() -> Option<Arc<Vec<NetworkInterface>>> {
|
||||
static SNAPSHOT: InterfaceSnapshot = RwLock::new(None);
|
||||
get_or_refresh_interfaces(&SNAPSHOT, || NetworkInterface::show().ok())
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
type InterfaceSnapshot = RwLock<Option<(Instant, Arc<Vec<NetworkInterface>>)>>;
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn get_or_refresh_interfaces(
|
||||
snapshot: &InterfaceSnapshot,
|
||||
enumerate: impl FnOnce() -> Option<Vec<NetworkInterface>>,
|
||||
) -> Option<Arc<Vec<NetworkInterface>>> {
|
||||
const SNAPSHOT_TTL: Duration = Duration::from_secs(1);
|
||||
if let Some((at, ifaces)) = snapshot.read().ok().and_then(|guard| guard.clone())
|
||||
&& at.elapsed() < SNAPSHOT_TTL
|
||||
{
|
||||
return Some(ifaces);
|
||||
}
|
||||
|
||||
// Recheck after acquiring the write lock: another caller may have refreshed
|
||||
// while we waited. Keep the lock during enumeration to coalesce refreshes.
|
||||
let mut snapshot = snapshot.write().ok()?;
|
||||
if let Some((at, ifaces)) = snapshot.as_ref()
|
||||
&& at.elapsed() < SNAPSHOT_TTL
|
||||
{
|
||||
return Some(ifaces.clone());
|
||||
}
|
||||
let ifaces = Arc::new(enumerate()?);
|
||||
*snapshot = Some((Instant::now(), ifaces.clone()));
|
||||
Some(ifaces)
|
||||
}
|
||||
|
||||
#[cfg(feature = "faketcp")]
|
||||
fn convert_pnet_interface(iface: pnet_datalink::NetworkInterface) -> NetworkInterface {
|
||||
let internal = iface.is_loopback();
|
||||
@@ -498,3 +544,60 @@ mod tests {
|
||||
assert_eq!(before, after);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod interface_snapshot_tests {
|
||||
use super::*;
|
||||
use std::sync::{
|
||||
Barrier,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn concurrent_refreshes_share_cold_and_expired_snapshots() {
|
||||
for initial in [
|
||||
None,
|
||||
Some((Instant::now() - Duration::from_secs(2), Arc::new(vec![]))),
|
||||
] {
|
||||
let snapshot = InterfaceSnapshot::new(initial);
|
||||
let calls = AtomicUsize::new(0);
|
||||
let barrier = Barrier::new(8);
|
||||
let results = std::thread::scope(|scope| {
|
||||
let handles: Vec<_> = (0..8)
|
||||
.map(|_| {
|
||||
scope.spawn(|| {
|
||||
barrier.wait();
|
||||
get_or_refresh_interfaces(&snapshot, || {
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
Some(vec![])
|
||||
})
|
||||
.unwrap()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
handles
|
||||
.into_iter()
|
||||
.map(|h| h.join().unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
for result in &results {
|
||||
assert!(Arc::ptr_eq(&results[0], result));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_refresh_is_retried_without_serving_expired_snapshot() {
|
||||
let snapshot = InterfaceSnapshot::new(Some((
|
||||
Instant::now() - Duration::from_secs(2),
|
||||
Arc::new(vec![]),
|
||||
)));
|
||||
assert!(get_or_refresh_interfaces(&snapshot, || None).is_none());
|
||||
let refreshed = get_or_refresh_interfaces(&snapshot, || Some(vec![])).unwrap();
|
||||
let cached =
|
||||
get_or_refresh_interfaces(&snapshot, || panic!("cache should be fresh")).unwrap();
|
||||
assert!(Arc::ptr_eq(&refreshed, &cached));
|
||||
}
|
||||
}
|
||||
|
||||
+158
-18
@@ -19,14 +19,17 @@ use crate::{
|
||||
};
|
||||
use anyhow::Context;
|
||||
use cidr::IpCidr;
|
||||
use clap::{Arg, ArgMatches, CommandFactory, FromArgMatches, Parser};
|
||||
use clap::{
|
||||
Arg, ArgMatches, CommandFactory, FromArgMatches, Parser, builder::TypedValueParser as _,
|
||||
};
|
||||
use easytier_core::config::{DhcpConfig, EncryptionAlgorithm, normalize_secure_mode_config};
|
||||
use guarden::defer;
|
||||
use prost_types::field_descriptor_proto::Type;
|
||||
use rust_i18n::t;
|
||||
use serde_json::{Value, json};
|
||||
use std::str::{FromStr, ParseBoolError};
|
||||
use std::str::FromStr;
|
||||
use std::{
|
||||
fmt::Write as _,
|
||||
net::{IpAddr, SocketAddr},
|
||||
path::PathBuf,
|
||||
process::ExitCode,
|
||||
@@ -129,6 +132,13 @@ struct Cli {
|
||||
)]
|
||||
config_dir: Option<PathBuf>,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "ET_SHARED_RUNTIME",
|
||||
help = t!("core_clap.shared_runtime").to_string()
|
||||
)]
|
||||
shared_runtime: bool,
|
||||
|
||||
#[command(flatten)]
|
||||
network_options: NetworkOptions,
|
||||
|
||||
@@ -503,6 +513,26 @@ struct RpcPortalOptions {
|
||||
}
|
||||
|
||||
impl Cli {
|
||||
fn take_runtime_thread_count(&mut self) -> u32 {
|
||||
let count = self
|
||||
.network_options
|
||||
.flags
|
||||
.multi_thread_count
|
||||
.unwrap_or_else(|| {
|
||||
if self.shared_runtime {
|
||||
std::thread::available_parallelism().map_or(1, |count| count.get() as u32)
|
||||
} else {
|
||||
Flags::defaults().multi_thread_count
|
||||
}
|
||||
});
|
||||
if self.shared_runtime {
|
||||
// Process runtime options must not make instance configs read-only.
|
||||
self.network_options.flags.multi_thread = None;
|
||||
self.network_options.flags.multi_thread_count = None;
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
fn gen_listeners(addr: SocketAddr) -> impl Iterator<Item = String> {
|
||||
let dynamic = addr.port() == 0;
|
||||
IpScheme::VARIANTS.iter().map(move |proto| {
|
||||
@@ -1018,8 +1048,8 @@ fn win_service_event_loop(
|
||||
|
||||
let normal_status = ServiceStatus {
|
||||
service_type: ServiceType::OWN_PROCESS,
|
||||
current_state: ServiceState::Running,
|
||||
controls_accepted: ServiceControlAccept::STOP,
|
||||
current_state: ServiceState::Stopped,
|
||||
controls_accepted: ServiceControlAccept::empty(),
|
||||
exit_code: ServiceExitCode::Win32(0),
|
||||
checkpoint: 0,
|
||||
wait_hint: Duration::default(),
|
||||
@@ -1102,9 +1132,9 @@ fn cli_command() -> clap::Command {
|
||||
value.parse::<T>().map(|value| json!(value))
|
||||
}
|
||||
|
||||
fn negated(value: &str) -> Result<Value, ParseBoolError> {
|
||||
value.parse::<bool>().map(|value| json!(!value))
|
||||
}
|
||||
let parser = clap::builder::BoolishValueParser::new();
|
||||
let boolish = parser.map(|v| json!(v));
|
||||
let negated = parser.map(|v| json!(!v));
|
||||
|
||||
Cli::command().args(Flags::flags().iter().map(|field| {
|
||||
let name = field.name();
|
||||
@@ -1115,11 +1145,11 @@ fn cli_command() -> clap::Command {
|
||||
.short('u')
|
||||
.num_args(0..=1)
|
||||
.default_missing_value("true")
|
||||
.value_parser(negated),
|
||||
.value_parser(negated.clone()),
|
||||
"enable_ipv6" => arg(name, "disable_ipv6")
|
||||
.num_args(0..=1)
|
||||
.default_missing_value("true")
|
||||
.value_parser(negated),
|
||||
.value_parser(negated.clone()),
|
||||
// The command line names the algorithm instead of spelling the field.
|
||||
// The values are the schema's own, which the patch reads.
|
||||
"data_compress_algo" => {
|
||||
@@ -1138,7 +1168,7 @@ fn cli_command() -> clap::Command {
|
||||
Type::Bool => arg
|
||||
.num_args(0..=1)
|
||||
.default_missing_value("true")
|
||||
.value_parser(parsed::<bool>),
|
||||
.value_parser(boolish.clone()),
|
||||
Type::Uint32 => arg.value_parser(parsed::<u32>),
|
||||
Type::Uint64 => arg.value_parser(parsed::<u64>),
|
||||
// An enum flag takes the name the schema spells, and the patch's
|
||||
@@ -1207,11 +1237,18 @@ fn win_service_main(arg: Vec<std::ffi::OsString>) {
|
||||
win_service_event_loop(stop_notify_recv, cli, status_handle);
|
||||
}
|
||||
|
||||
async fn run_main(cli: Cli) -> anyhow::Result<()> {
|
||||
async fn run_main(mut cli: Cli) -> anyhow::Result<()> {
|
||||
defer!(dump_profile(0););
|
||||
log::init(&cli.logging_options, true)?;
|
||||
log::init_with_default_console_targets(
|
||||
&cli.logging_options,
|
||||
true,
|
||||
&["CORE", "easytier_core::instance::manager"],
|
||||
)?;
|
||||
|
||||
let manager = Arc::new(native_cli_instance_manager().with_config_path(cli.config_dir.clone()));
|
||||
let manager = Arc::new(
|
||||
native_cli_instance_manager(cli.shared_runtime, cli.take_runtime_thread_count())?
|
||||
.with_config_path(cli.config_dir.clone()),
|
||||
);
|
||||
|
||||
let _rpc_server = ApiRpcServer::new(
|
||||
cli.rpc_portal_options.rpc_portal,
|
||||
@@ -1358,10 +1395,17 @@ async fn run_main(cli: Cli) -> anyhow::Result<()> {
|
||||
tokio::select! {
|
||||
_ = manager.wait() => {
|
||||
let infos = manager.collect_network_infos().await?;
|
||||
if infos
|
||||
.into_values()
|
||||
.filter_map(|info| info.error_msg).next().is_some() {
|
||||
return Err(anyhow::anyhow!("some instances stopped with errors"));
|
||||
let mut errors = String::new();
|
||||
for (instance_id, info) in infos {
|
||||
if let Some(error) = info.error_msg {
|
||||
if errors.is_empty() {
|
||||
errors.push_str("some instances stopped with errors:");
|
||||
}
|
||||
write!(errors, "\n{instance_id}: {error}").unwrap();
|
||||
}
|
||||
}
|
||||
if !errors.is_empty() {
|
||||
return Err(anyhow::anyhow!(errors));
|
||||
}
|
||||
}
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
@@ -1487,7 +1531,7 @@ pub async fn main() -> ExitCode {
|
||||
let mut ret_code = 0;
|
||||
|
||||
if let Err(error) = run_main(cli).await {
|
||||
log::error!(%error);
|
||||
log::error!(error = %format_args!("{error:#}"));
|
||||
ret_code = 1;
|
||||
}
|
||||
|
||||
@@ -1530,6 +1574,64 @@ mod tests {
|
||||
flags_from(&cli_command().try_get_matches_from(argv)?)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_runtime_is_process_only_and_reuses_thread_count() {
|
||||
let cpus = std::thread::available_parallelism().map_or(1, |count| count.get() as u32);
|
||||
for (args, shared, expected_count) in [
|
||||
(vec![], false, Flags::defaults().multi_thread_count),
|
||||
(vec!["--shared-runtime"], true, cpus),
|
||||
(vec!["--multi-thread-count", "4"], false, 4),
|
||||
(
|
||||
vec![
|
||||
"--shared-runtime",
|
||||
"--multi-thread-count",
|
||||
"1",
|
||||
"--multi-thread",
|
||||
"false",
|
||||
],
|
||||
true,
|
||||
1,
|
||||
),
|
||||
(
|
||||
vec![
|
||||
"--shared-runtime",
|
||||
"--multi-thread-count",
|
||||
"4",
|
||||
"-c",
|
||||
"a.toml",
|
||||
"b.toml",
|
||||
],
|
||||
true,
|
||||
4,
|
||||
),
|
||||
] {
|
||||
let matches = cli_command()
|
||||
.try_get_matches_from(std::iter::once("easytier-core").chain(args))
|
||||
.unwrap();
|
||||
let mut cli = Cli::from_arg_matches(&matches).unwrap();
|
||||
cli.network_options.flags = flags_from(&matches).unwrap();
|
||||
assert_eq!(cli.shared_runtime, shared);
|
||||
assert_eq!(cli.take_runtime_thread_count(), expected_count);
|
||||
|
||||
let config = TomlConfigLoader::default();
|
||||
if shared {
|
||||
assert_eq!(cli.network_options.flags.multi_thread, None);
|
||||
assert_eq!(cli.network_options.flags.multi_thread_count, None);
|
||||
assert!(!cli.network_options.can_merge(
|
||||
&config,
|
||||
ConfigFileSource::CliConfigFile,
|
||||
1,
|
||||
0,
|
||||
));
|
||||
}
|
||||
cli.network_options.merge_into(&config).unwrap();
|
||||
assert!(!config.dump().contains("shared_runtime"));
|
||||
if cli.network_options.flags.multi_thread_count.is_none() {
|
||||
assert_eq!(config.get_flags_patch().multi_thread_count, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_listeners() {
|
||||
type IpSchemeMap = fn(&IpScheme) -> String;
|
||||
@@ -1772,6 +1874,44 @@ enabled = true
|
||||
let patch = parse_flags(&["easytier", "--disable-ipv6"]).unwrap();
|
||||
assert_eq!(patch.enable_ipv6, Some(false));
|
||||
|
||||
// Boolish tests: accepts 1/0, yes/no, on/off, true/false
|
||||
assert_eq!(
|
||||
parse_flags(&["easytier", "--latency-first", "1"])
|
||||
.unwrap()
|
||||
.latency_first,
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_flags(&["easytier", "--latency-first", "0"])
|
||||
.unwrap()
|
||||
.latency_first,
|
||||
Some(false)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_flags(&["easytier", "--latency-first", "yes"])
|
||||
.unwrap()
|
||||
.latency_first,
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_flags(&["easytier", "--latency-first", "no"])
|
||||
.unwrap()
|
||||
.latency_first,
|
||||
Some(false)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_flags(&["easytier", "--disable-encryption", "1"])
|
||||
.unwrap()
|
||||
.enable_encryption,
|
||||
Some(false)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_flags(&["easytier", "--disable-encryption", "0"])
|
||||
.unwrap()
|
||||
.enable_encryption,
|
||||
Some(true)
|
||||
);
|
||||
|
||||
// Compression tests: case-insensitive, accepts None/none/zstd/Zstd/ZSTD, rejects invalid
|
||||
let patch = parse_flags(&["easytier", "--compression", "none"]).unwrap();
|
||||
assert_eq!(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use std::{
|
||||
mem::MaybeUninit,
|
||||
net::{IpAddr, Ipv4Addr, SocketAddrV4},
|
||||
sync::Arc,
|
||||
};
|
||||
@@ -9,6 +8,8 @@ use easytier_core::{
|
||||
socket::SocketContext,
|
||||
};
|
||||
use socket2::Socket;
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::common::netns::NetNS;
|
||||
|
||||
@@ -27,39 +28,43 @@ impl RuntimeIcmpProxyHost {
|
||||
Ipv4Addr::UNSPECIFIED,
|
||||
0,
|
||||
)))?;
|
||||
socket.set_nonblocking(true)?;
|
||||
Ok(socket)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RuntimeIcmpSocket {
|
||||
socket: Arc<Socket>,
|
||||
socket: UdpSocket,
|
||||
cancel: CancellationToken,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl IcmpProxySocket for RuntimeIcmpSocket {
|
||||
async fn send(&self, destination: Ipv4Addr, packet: &[u8]) -> Result<(), ProxyRuntimeError> {
|
||||
self.socket
|
||||
.send_to(packet, &SocketAddrV4::new(destination, 0).into())?;
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = self.cancel.cancelled() => return Err(std::io::Error::from(std::io::ErrorKind::NotConnected).into()),
|
||||
result = self.socket.send_to(packet, SocketAddrV4::new(destination, 0)) => { result?; }
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn recv(&self) -> Result<(IpAddr, Vec<u8>), ProxyRuntimeError> {
|
||||
let socket = self.socket.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut buffer = vec![0_u8; 8192];
|
||||
let uninitialized: &mut [MaybeUninit<u8>] =
|
||||
unsafe { std::mem::transmute(&mut buffer[..]) };
|
||||
let (length, peer_ip) = socket_recv(&socket, uninitialized)?;
|
||||
buffer.truncate(length);
|
||||
Ok((peer_ip, buffer))
|
||||
})
|
||||
.await
|
||||
.map_err(|error| ProxyRuntimeError::Other(error.into()))?
|
||||
let mut buffer = vec![0_u8; 8192];
|
||||
let (length, peer) = tokio::select! {
|
||||
biased;
|
||||
// The proxy receive loop terminates on an empty packet. A permanent
|
||||
// error would be retried immediately and could prevent task abortion.
|
||||
_ = self.cancel.cancelled() => return Ok((IpAddr::V4(Ipv4Addr::UNSPECIFIED), Vec::new())),
|
||||
result = self.socket.recv_from(&mut buffer) => result?,
|
||||
};
|
||||
buffer.truncate(length);
|
||||
Ok((peer.ip(), buffer))
|
||||
}
|
||||
|
||||
fn close(&self) {
|
||||
let _ = self.socket.shutdown(std::net::Shutdown::Both);
|
||||
self.cancel.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,20 +77,136 @@ impl IcmpProxyHost for RuntimeIcmpProxyHost {
|
||||
let socket = Self::create_raw_socket(&context).inspect_err(|error| {
|
||||
tracing::warn!(?error, "create ICMP socket failed");
|
||||
})?;
|
||||
// Tokio only supplies readiness and recvfrom/sendto here; the underlying
|
||||
// socket remains raw ICMP, including the IPv4 header on received packets.
|
||||
Ok(Arc::new(RuntimeIcmpSocket {
|
||||
socket: Arc::new(socket),
|
||||
socket: UdpSocket::from_std(socket.into())?,
|
||||
cancel: CancellationToken::new(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn socket_recv(
|
||||
socket: &Socket,
|
||||
buffer: &mut [MaybeUninit<u8>],
|
||||
) -> Result<(usize, IpAddr), std::io::Error> {
|
||||
let (size, address) = socket.recv_from(buffer)?;
|
||||
let peer_ip = address
|
||||
.as_socket()
|
||||
.map(|address| address.ip())
|
||||
.unwrap_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED));
|
||||
Ok((size, peer_ip))
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn close_ends_receivers_and_rejects_further_sends() {
|
||||
let socket = RuntimeIcmpSocket {
|
||||
socket: UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap(),
|
||||
cancel: CancellationToken::new(),
|
||||
};
|
||||
let mut first = Box::pin(socket.recv());
|
||||
let mut second = Box::pin(socket.recv());
|
||||
assert!(futures::poll!(&mut first).is_pending());
|
||||
assert!(futures::poll!(&mut second).is_pending());
|
||||
|
||||
socket.close();
|
||||
socket.close();
|
||||
tokio::time::timeout(Duration::from_secs(1), async {
|
||||
assert!(first.await.unwrap().1.is_empty());
|
||||
assert!(second.await.unwrap().1.is_empty());
|
||||
assert!(socket.recv().await.unwrap().1.is_empty());
|
||||
assert!(socket.send(Ipv4Addr::LOCALHOST, &[0; 8]).await.is_err());
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelled_receive_does_not_consume_the_next_packet() {
|
||||
let socket = RuntimeIcmpSocket {
|
||||
socket: UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap(),
|
||||
cancel: CancellationToken::new(),
|
||||
};
|
||||
let mut receive = Box::pin(socket.recv());
|
||||
assert!(futures::poll!(&mut receive).is_pending());
|
||||
drop(receive);
|
||||
|
||||
let sender = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
|
||||
sender
|
||||
.send_to(b"after cancellation", socket.socket.local_addr().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let (peer, packet) = tokio::time::timeout(Duration::from_secs(1), socket.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(peer, IpAddr::V4(Ipv4Addr::LOCALHOST));
|
||||
assert_eq!(packet, b"after cancellation");
|
||||
}
|
||||
|
||||
// Raw ICMP requires CAP_NET_RAW; run this test in the privileged test container.
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn raw_echo_fanout_does_not_start_blocking_threads() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
let threads = Arc::new(AtomicUsize::new(0));
|
||||
let started = threads.clone();
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.on_thread_start(move || {
|
||||
started.fetch_add(1, Ordering::Relaxed);
|
||||
})
|
||||
.build()
|
||||
.unwrap();
|
||||
let result = runtime.block_on(async {
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
let mut sockets = Vec::new();
|
||||
for _ in 0..30 {
|
||||
sockets.push(
|
||||
RuntimeIcmpProxyHost
|
||||
.open_icmp_v4(SocketContext::default())
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
let identifier = rand::random::<u16>().to_be_bytes();
|
||||
for sequence in 0..16 {
|
||||
let mut request = vec![8, 0, 0, 0, identifier[0], identifier[1], 0, sequence];
|
||||
request.extend_from_slice(b"async ICMP");
|
||||
let mut checksum: u32 = request
|
||||
.chunks_exact(2)
|
||||
.map(|word| u32::from(u16::from_be_bytes([word[0], word[1]])))
|
||||
.sum();
|
||||
while checksum >> 16 != 0 {
|
||||
checksum = (checksum & 0xffff) + (checksum >> 16);
|
||||
}
|
||||
request[2..4].copy_from_slice(&(!(checksum as u16)).to_be_bytes());
|
||||
let receivers = futures::future::join_all(sockets.iter().map(|socket| async {
|
||||
loop {
|
||||
let (peer, packet) = socket.recv().await.unwrap();
|
||||
let header_len = usize::from(packet[0] & 0x0f) * 4;
|
||||
let Some(reply) = packet.get(header_len..) else {
|
||||
continue;
|
||||
};
|
||||
if reply.len() == request.len()
|
||||
&& reply[0] == 0
|
||||
&& reply[4..] == request[4..]
|
||||
{
|
||||
assert_eq!(peer, IpAddr::V4(Ipv4Addr::LOCALHOST));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}));
|
||||
tokio::join!(receivers, async {
|
||||
sockets[0]
|
||||
.send(Ipv4Addr::LOCALHOST, &request)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
}
|
||||
for socket in sockets {
|
||||
socket.close();
|
||||
}
|
||||
})
|
||||
.await
|
||||
});
|
||||
runtime.shutdown_timeout(Duration::from_secs(1));
|
||||
result.unwrap();
|
||||
assert_eq!(threads.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ pub(super) fn compose_native_core_instance(
|
||||
process_runtime: Arc<CoreProcessRuntime>,
|
||||
#[cfg(feature = "tun")] shared_virtual_nic_registry: ArcSharedVirtualNicRegistry,
|
||||
compact_runtime: bool,
|
||||
executor: Option<NativeInstanceExecutor>,
|
||||
executor: Option<Arc<NativeInstanceExecutor>>,
|
||||
) -> anyhow::Result<Arc<NativeCoreInstance>> {
|
||||
let host_config = if compact_runtime {
|
||||
compact_runtime_core_host_config()
|
||||
|
||||
@@ -41,12 +41,16 @@ pub fn native_instance_manager() -> NativeInstanceManager {
|
||||
}
|
||||
|
||||
#[cfg(feature = "management")]
|
||||
pub fn native_cli_instance_manager() -> NativeInstanceManager {
|
||||
pub fn native_cli_instance_manager(
|
||||
shared_runtime: bool,
|
||||
thread_count: u32,
|
||||
) -> anyhow::Result<NativeInstanceManager> {
|
||||
let process_runtime = CoreProcessRuntime::new();
|
||||
InstanceManager::new(
|
||||
NativeInstanceFactory::new(process_runtime).with_cli_event_logging(),
|
||||
None,
|
||||
)
|
||||
let mut factory = NativeInstanceFactory::new(process_runtime).with_cli_event_logging();
|
||||
if shared_runtime {
|
||||
factory.shared_executor = Some(Arc::new(NativeInstanceExecutor::new(true, thread_count)?));
|
||||
}
|
||||
Ok(InstanceManager::new(factory, None))
|
||||
}
|
||||
|
||||
pub fn create_native_instance(config: TomlConfig) -> anyhow::Result<Arc<NativeCoreInstance>> {
|
||||
@@ -111,6 +115,7 @@ pub struct NativeInstanceFactory {
|
||||
#[cfg(feature = "tun")]
|
||||
shared_virtual_nic_registry: ArcSharedVirtualNicRegistry,
|
||||
runtime_handle: Option<tokio::runtime::Handle>,
|
||||
shared_executor: Option<Arc<NativeInstanceExecutor>>,
|
||||
compact_runtime: bool,
|
||||
#[cfg(feature = "logging")]
|
||||
log_cli_events: bool,
|
||||
@@ -123,6 +128,7 @@ impl NativeInstanceFactory {
|
||||
#[cfg(feature = "tun")]
|
||||
shared_virtual_nic_registry: Arc::new(Mutex::new(SharedVirtualNicRegistry::new())),
|
||||
runtime_handle: None,
|
||||
shared_executor: None,
|
||||
compact_runtime: false,
|
||||
#[cfg(feature = "logging")]
|
||||
log_cli_events: false,
|
||||
@@ -160,16 +166,18 @@ impl InstanceFactory for NativeInstanceFactory {
|
||||
) -> Result<Arc<Self::Instance>, Self::Error> {
|
||||
let executor = if self.compact_runtime {
|
||||
None
|
||||
} else if let Some(executor) = &self.shared_executor {
|
||||
Some(executor.clone())
|
||||
} else {
|
||||
let flags = config.get_flags();
|
||||
Some(NativeInstanceExecutor::new(
|
||||
Some(Arc::new(NativeInstanceExecutor::new(
|
||||
flags.multi_thread,
|
||||
flags.multi_thread_count,
|
||||
)?)
|
||||
flags.multi_thread_count.max(2),
|
||||
)?))
|
||||
};
|
||||
let runtime_handle = executor
|
||||
.as_ref()
|
||||
.map(NativeInstanceExecutor::handle)
|
||||
.map(|executor| executor.handle())
|
||||
.or_else(|| self.runtime_handle.clone());
|
||||
let _runtime = runtime_handle.as_ref().map(tokio::runtime::Handle::enter);
|
||||
let instance = compose_native_core_instance(
|
||||
@@ -231,7 +239,9 @@ mod tests {
|
||||
let manager = native_instance_manager_with_runtime(caller.clone());
|
||||
let mut runtimes = Vec::new();
|
||||
let mut ids = Vec::new();
|
||||
for (multi_thread, count, workers) in [(false, 8, 1), (true, 4, 4)] {
|
||||
for (multi_thread, count, workers) in
|
||||
[(false, 8, 1), (true, 4, 4), (true, 0, 2), (true, 1, 2)]
|
||||
{
|
||||
let config = isolated_config(multi_thread, count);
|
||||
let id = manager
|
||||
.run_network_instance(config, ConfigFileControl::STATIC_CONFIG)
|
||||
@@ -311,6 +321,89 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "management")]
|
||||
#[tokio::test]
|
||||
async fn shared_runtime_survives_instance_deletion_and_stops_with_last_owner() {
|
||||
for workers in [1, 4] {
|
||||
let manager = native_cli_instance_manager(true, workers).unwrap();
|
||||
let first = manager.create(isolated_config(false, 8), ()).unwrap();
|
||||
let second = manager.create(isolated_config(true, 2), ()).unwrap();
|
||||
let runtime = first.runtime_handle().unwrap();
|
||||
assert_ne!(runtime.id(), tokio::runtime::Handle::current().id());
|
||||
assert_eq!(runtime.id(), second.runtime_handle().unwrap().id());
|
||||
assert_eq!(runtime.runtime_flavor(), RuntimeFlavor::MultiThread);
|
||||
assert_eq!(runtime.metrics().num_workers(), workers as usize);
|
||||
|
||||
first.start().await.unwrap();
|
||||
second.start().await.unwrap();
|
||||
second
|
||||
.add_connector(format!("ring://{}", first.instance_id()).parse().unwrap())
|
||||
.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
while !first.connected_peers().await.contains(&second.peer_id())
|
||||
|| !second.connected_peers().await.contains(&first.peer_id())
|
||||
{
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("instances must connect on the shared runtime");
|
||||
|
||||
manager
|
||||
.delete_network_instances([first.instance_id()])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(first.state(), CoreInstanceState::Stopped);
|
||||
drop(first);
|
||||
assert_eq!(second.state(), CoreInstanceState::Running);
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
runtime.spawn(async {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let added = manager.create(isolated_config(false, 16), ()).unwrap();
|
||||
assert_eq!(runtime.id(), added.runtime_handle().unwrap().id());
|
||||
added.start().await.unwrap();
|
||||
added
|
||||
.add_connector(format!("ring://{}", second.instance_id()).parse().unwrap())
|
||||
.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
while !second.connected_peers().await.contains(&added.peer_id())
|
||||
|| !added.connected_peers().await.contains(&second.peer_id())
|
||||
{
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("deleting one instance must leave the others functional");
|
||||
manager
|
||||
.delete_network_instances([added.instance_id(), second.instance_id()])
|
||||
.await
|
||||
.unwrap();
|
||||
drop(added);
|
||||
assert_eq!(second.state(), CoreInstanceState::Stopped);
|
||||
|
||||
// A surviving instance owner keeps the executor alive after its manager.
|
||||
let task = runtime.spawn(pending::<()>());
|
||||
drop(manager);
|
||||
runtime.spawn(async {}).await.unwrap();
|
||||
assert!(!task.is_finished());
|
||||
drop(second);
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_secs(5), task)
|
||||
.await
|
||||
.expect("the last owner must shut down the shared executor")
|
||||
.unwrap_err()
|
||||
.is_cancelled()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_instance_outlives_its_callers_runtime() {
|
||||
for multi_thread in [false, true] {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Native ownership and continuous driving of one instance's Tokio runtime.
|
||||
//! Native ownership and continuous driving of an instance or shared Tokio runtime.
|
||||
|
||||
use std::sync::mpsc;
|
||||
|
||||
@@ -22,7 +22,7 @@ impl NativeInstanceExecutor {
|
||||
.spawn(move || {
|
||||
let mut builder = if multi_thread {
|
||||
let mut builder = Builder::new_multi_thread();
|
||||
builder.worker_threads(2.max(thread_count as usize));
|
||||
builder.worker_threads(1.max(thread_count as usize));
|
||||
builder
|
||||
} else {
|
||||
Builder::new_current_thread()
|
||||
@@ -67,8 +67,8 @@ mod tests {
|
||||
async fn honors_runtime_flavor_and_worker_count() {
|
||||
for (multi_thread, count, expected_workers) in [
|
||||
(false, 8, 1),
|
||||
(true, 0, 2),
|
||||
(true, 1, 2),
|
||||
(true, 0, 1),
|
||||
(true, 1, 1),
|
||||
(true, 2, 2),
|
||||
(true, 4, 4),
|
||||
] {
|
||||
|
||||
@@ -41,7 +41,7 @@ pub(crate) struct NativeInstanceRuntimeHost {
|
||||
cancel: CancellationToken,
|
||||
event_journal: EventJournal,
|
||||
tun: NativeTunRuntime,
|
||||
executor: Option<NativeInstanceExecutor>,
|
||||
executor: Option<Arc<NativeInstanceExecutor>>,
|
||||
}
|
||||
|
||||
impl NativeInstanceRuntimeHost {
|
||||
@@ -61,7 +61,7 @@ impl NativeInstanceRuntimeHost {
|
||||
pub(super) fn new_with_executor(
|
||||
global_ctx: ArcGlobalCtx,
|
||||
#[cfg(feature = "tun")] shared_virtual_nic_registry: ArcSharedVirtualNicRegistry,
|
||||
executor: Option<NativeInstanceExecutor>,
|
||||
executor: Option<Arc<NativeInstanceExecutor>>,
|
||||
) -> Arc<Self> {
|
||||
let cancel = CancellationToken::new();
|
||||
let tun = NativeTunRuntime::new(
|
||||
|
||||
@@ -870,20 +870,28 @@ mod tests {
|
||||
let suffix = rand::thread_rng().gen_range(10000..99999);
|
||||
let capture_name = format!("elbc{suffix}");
|
||||
let sender_name = format!("elbs{suffix}");
|
||||
// Assign MACs at creation so udev does not replace them after readback.
|
||||
run_ip(&[
|
||||
"link",
|
||||
"add",
|
||||
&capture_name,
|
||||
"address",
|
||||
"02:00:00:00:00:01",
|
||||
"type",
|
||||
"veth",
|
||||
"peer",
|
||||
"name",
|
||||
&sender_name,
|
||||
"address",
|
||||
"02:00:00:00:00:02",
|
||||
])?;
|
||||
|
||||
let setup_result = (|| {
|
||||
run_ip(&["link", "set", "dev", &capture_name, "up"])?;
|
||||
run_ip(&["link", "set", "dev", &sender_name, "up"])?;
|
||||
// A single-device GETLINK synchronizes pending linkwatch work,
|
||||
// activating the first endpoint's TX queue before any test sends.
|
||||
run_ip(&["link", "show", "dev", &capture_name])?;
|
||||
let interfaces = datalink::interfaces();
|
||||
let find_mac = |name: &str| {
|
||||
interfaces
|
||||
|
||||
+76
-26
@@ -157,16 +157,6 @@ impl RuntimeUdpSocketFactory {
|
||||
|
||||
BindDev::Disabled
|
||||
}
|
||||
|
||||
fn reuse_addr_for(&self, options: &UdpBindOptions) -> bool {
|
||||
options.reuse_addr
|
||||
|| (matches!(
|
||||
options.purpose,
|
||||
UdpSocketPurpose::PortBoundListener
|
||||
| UdpSocketPurpose::ProxyNat
|
||||
| UdpSocketPurpose::PortForward
|
||||
) && !cfg!(target_os = "windows"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a host-owned UDP socket, including any required protection, before
|
||||
@@ -181,7 +171,9 @@ pub(crate) async fn create_udp_socket(options: &UdpBindOptions) -> anyhow::Resul
|
||||
.dev(factory.bind_device_for(options))
|
||||
.net_ns(NetNS::from_socket_context(&options.context))
|
||||
.only_v6(options.only_v6)
|
||||
.reuse_addr(factory.reuse_addr_for(options))
|
||||
// Independent UDP receivers must not share a bound endpoint, including
|
||||
// kernel-assigned ports. Only an explicit request may enable reuse.
|
||||
.reuse_addr(options.reuse_addr)
|
||||
.reuse_port(options.reuse_port)
|
||||
.maybe_socket_mark(options.context.socket_mark)
|
||||
.need_protect(options.need_protect)
|
||||
@@ -216,6 +208,78 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[tokio::test]
|
||||
async fn runtime_udp_bind_rejects_occupied_port() {
|
||||
for local_addr in ["0.0.0.0:0", "[::]:0"] {
|
||||
let local_addr = local_addr.parse().unwrap();
|
||||
for bind in [
|
||||
UdpBindOptions::port_bound_listener(local_addr),
|
||||
UdpBindOptions::proxy_nat().with_local_addr(Some(local_addr)),
|
||||
UdpBindOptions::port_forward(local_addr),
|
||||
] {
|
||||
let bind = bind.with_only_v6(true);
|
||||
let listener = create_udp_socket(&bind).await.unwrap();
|
||||
let occupied = bind.with_local_addr(Some(listener.local_addr().unwrap()));
|
||||
|
||||
for reuse_addr in [false, true] {
|
||||
let error = create_udp_socket(&occupied.clone().with_reuse_addr(reuse_addr))
|
||||
.await
|
||||
.expect_err("another socket must not share a bound receive endpoint");
|
||||
assert!(matches!(
|
||||
error.downcast_ref::<easytier_core::tunnel::TunnelError>(),
|
||||
Some(easytier_core::tunnel::TunnelError::IOError(error))
|
||||
if error.kind() == std::io::ErrorKind::AddrInUse
|
||||
));
|
||||
}
|
||||
|
||||
drop(listener);
|
||||
create_udp_socket(&occupied).await.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_udp_listeners_keep_ephemeral_ports_and_packets_separate() {
|
||||
for (bind_addr, loopback) in [
|
||||
("0.0.0.0:0", "127.0.0.1".parse::<IpAddr>().unwrap()),
|
||||
("[::]:0", "::1".parse::<IpAddr>().unwrap()),
|
||||
] {
|
||||
let bind_addr = bind_addr.parse().unwrap();
|
||||
let binds = [
|
||||
UdpBindOptions::port_bound_listener(bind_addr),
|
||||
UdpBindOptions::proxy_nat().with_local_addr(Some(bind_addr)),
|
||||
UdpBindOptions::port_forward(bind_addr),
|
||||
];
|
||||
let mut listeners = Vec::new();
|
||||
let mut ports = std::collections::HashSet::new();
|
||||
for index in 0..128 {
|
||||
let bind = binds[index % binds.len()].clone().with_only_v6(true);
|
||||
let listener = create_udp_socket(&bind).await.unwrap();
|
||||
assert!(ports.insert(listener.local_addr().unwrap().port()));
|
||||
listeners.push(listener);
|
||||
}
|
||||
|
||||
let sender = UdpSocket::bind(SocketAddr::new(loopback, 0)).await.unwrap();
|
||||
for (index, listener) in listeners.iter().enumerate() {
|
||||
let destination = SocketAddr::new(loopback, listener.local_addr().unwrap().port());
|
||||
sender.send_to(&[index as u8], destination).await.unwrap();
|
||||
}
|
||||
for (index, listener) in listeners.iter().enumerate() {
|
||||
let mut packet = [0; 16];
|
||||
let (len, source) = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(2),
|
||||
listener.recv_from(&mut packet),
|
||||
)
|
||||
.await
|
||||
.expect("listener did not receive its packet")
|
||||
.unwrap();
|
||||
assert_eq!(&packet[..len], &[index as u8]);
|
||||
assert_eq!(source, sender.local_addr().unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
#[tokio::test]
|
||||
async fn runtime_udp_socket_reports_ipv4_destination_ip() {
|
||||
@@ -360,7 +424,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_interprets_bind_defaults_by_purpose() {
|
||||
fn factory_interprets_bind_device_by_purpose() {
|
||||
let listener_addr = SocketAddr::from(([0, 0, 0, 0], 11010));
|
||||
let factory = RuntimeUdpSocketFactory::new();
|
||||
|
||||
@@ -384,20 +448,6 @@ mod tests {
|
||||
factory.bind_device_for(&UdpBindOptions::hole_punch_control()),
|
||||
BindDev::Disabled
|
||||
));
|
||||
assert_eq!(
|
||||
factory.reuse_addr_for(&UdpBindOptions::port_bound_listener(listener_addr)),
|
||||
!cfg!(target_os = "windows")
|
||||
);
|
||||
assert!(!factory.reuse_addr_for(&UdpBindOptions::hole_punch_control()));
|
||||
assert_eq!(
|
||||
factory.reuse_addr_for(&UdpBindOptions::proxy_nat()),
|
||||
!cfg!(target_os = "windows")
|
||||
);
|
||||
assert_eq!(
|
||||
factory.reuse_addr_for(&UdpBindOptions::port_forward(listener_addr)),
|
||||
!cfg!(target_os = "windows")
|
||||
);
|
||||
assert!(!factory.reuse_addr_for(&UdpBindOptions::port_lease(listener_addr)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -46,6 +46,14 @@ pub(crate) async fn recv_from_with_dst_ip(
|
||||
loop {
|
||||
match recv_from_with_dst_ip_once(socket, buf) {
|
||||
Err(err) if err.kind() == io::ErrorKind::Interrupted => continue,
|
||||
// WSARecvMsg consumed this oversized datagram. Discard it
|
||||
// just as the Unix adapter discards MSG_TRUNC packets.
|
||||
Err(err)
|
||||
if err.raw_os_error()
|
||||
== Some(windows::Win32::Networking::WinSock::WSAEMSGSIZE.0) =>
|
||||
{
|
||||
continue;
|
||||
}
|
||||
ret => break ret,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -554,18 +554,24 @@ fn shared_tun_claim_cleanup_outlives_member_runtime() {
|
||||
#[case::static_ipv4(false)]
|
||||
#[case::with_dhcpv6(true)]
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn same_namespace_members_share_tun_across_independent_networks(#[case] dhcpv6: bool) {
|
||||
let dev_name = test_dev_name();
|
||||
let first_peer_ns = TestNetnsGuard::new(short_name("sva"), "10.231.1.2/24", "fd31::2/64");
|
||||
let second_peer_ns = TestNetnsGuard::new(short_name("svb"), "10.231.2.2/24", "fd32::2/64");
|
||||
let mut network = TestNetwork::new();
|
||||
// Nextest runs each case in its own process, so their shared TUNs must
|
||||
// also have separate namespaces to avoid competing routes for the same IPs.
|
||||
network.create_empty_namespace("shared");
|
||||
network.create_namespace("first_peer", "10.231.1.2/24", "fd31::2/64");
|
||||
network.create_namespace("second_peer", "10.231.2.2/24", "fd32::2/64");
|
||||
let shared_ns = network.namespace("shared");
|
||||
let first_peer_ns = network.namespace("first_peer");
|
||||
let second_peer_ns = network.namespace("second_peer");
|
||||
let runtime = SharedTestRuntime::new();
|
||||
|
||||
let first_config = test_config(
|
||||
"shared_tun_first",
|
||||
"shared_tun_network_a",
|
||||
"shared_tun_secret_a",
|
||||
None,
|
||||
Some(shared_ns),
|
||||
Some(&dev_name),
|
||||
"10.144.250.1/24",
|
||||
);
|
||||
@@ -579,7 +585,7 @@ async fn same_namespace_members_share_tun_across_independent_networks(#[case] dh
|
||||
"shared_tun_second",
|
||||
"shared_tun_network_b",
|
||||
"shared_tun_secret_b",
|
||||
None,
|
||||
Some(shared_ns),
|
||||
Some(&dev_name),
|
||||
"10.144.251.1/24",
|
||||
);
|
||||
@@ -593,7 +599,7 @@ async fn same_namespace_members_share_tun_across_independent_networks(#[case] dh
|
||||
"shared_tun_first_peer",
|
||||
"shared_tun_network_a",
|
||||
"shared_tun_secret_a",
|
||||
Some(&first_peer_ns.name),
|
||||
Some(first_peer_ns),
|
||||
None,
|
||||
"10.144.250.2/24",
|
||||
);
|
||||
@@ -605,7 +611,7 @@ async fn same_namespace_members_share_tun_across_independent_networks(#[case] dh
|
||||
"shared_tun_second_peer",
|
||||
"shared_tun_network_b",
|
||||
"shared_tun_secret_b",
|
||||
Some(&second_peer_ns.name),
|
||||
Some(second_peer_ns),
|
||||
None,
|
||||
"10.144.251.2/24",
|
||||
);
|
||||
@@ -648,21 +654,18 @@ async fn same_namespace_members_share_tun_across_independent_networks(#[case] dh
|
||||
)
|
||||
.await;
|
||||
wait_for_condition(
|
||||
|| async { ping_test(&first_peer_ns.name, "10.144.250.1", None).await },
|
||||
|| async { ping_test(first_peer_ns, "10.144.250.1", None).await },
|
||||
WAIT,
|
||||
)
|
||||
.await;
|
||||
wait_for_condition(
|
||||
|| async { ping_test(&second_peer_ns.name, "10.144.251.1", None).await },
|
||||
|| async { ping_test(second_peer_ns, "10.144.251.1", None).await },
|
||||
WAIT,
|
||||
)
|
||||
.await;
|
||||
|
||||
if dhcpv6 {
|
||||
for (instance, ns) in [
|
||||
(&first, &first_peer_ns.name),
|
||||
(&second, &second_peer_ns.name),
|
||||
] {
|
||||
for (instance, ns) in [(&first, first_peer_ns), (&second, second_peer_ns)] {
|
||||
wait_for_condition(
|
||||
|| async { instance.get_global_ctx().get_ipv6().is_some() },
|
||||
WAIT,
|
||||
@@ -678,10 +681,10 @@ async fn same_namespace_members_share_tun_across_independent_networks(#[case] dh
|
||||
}
|
||||
// Removing one DHCP member must leave the other member's addresses
|
||||
// and the physical TUN intact.
|
||||
let original_index = tun_ifindex(ROOT_NETNS_NAME, &dev_name);
|
||||
let original_index = tun_ifindex(shared_ns, &dev_name);
|
||||
assert!(original_index.is_some());
|
||||
drop_insts(vec![first]).await;
|
||||
assert_eq!(tun_ifindex(ROOT_NETNS_NAME, &dev_name), original_index);
|
||||
assert_eq!(tun_ifindex(shared_ns, &dev_name), original_index);
|
||||
let address = second
|
||||
.get_global_ctx()
|
||||
.get_ipv6()
|
||||
@@ -689,7 +692,7 @@ async fn same_namespace_members_share_tun_across_independent_networks(#[case] dh
|
||||
.address()
|
||||
.to_string();
|
||||
wait_for_condition(
|
||||
|| async { ping_test(&second_peer_ns.name, &address, None).await },
|
||||
|| async { ping_test(second_peer_ns, &address, None).await },
|
||||
WAIT,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -42,6 +42,7 @@ use crate::{
|
||||
},
|
||||
tunnel::common::tests::{
|
||||
_tunnel_bench_netns, _tunnel_pingpong_netns_with_timeout, wait_for_condition,
|
||||
wait_for_condition_named,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -456,6 +457,8 @@ fn connect_ns_to_bridge(ns: &str, guest_if: &str, host_if: &str, bridge: &str) {
|
||||
|
||||
struct PublicIpv6Lab {
|
||||
network: TestNetwork,
|
||||
#[cfg(feature = "tun")]
|
||||
shared_virtual_nic_registry: crate::instance::shared_virtual_nic::ArcSharedVirtualNicRegistry,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -642,7 +645,30 @@ impl PublicIpv6Lab {
|
||||
"net.ipv6.conf.all.forwarding=1",
|
||||
);
|
||||
|
||||
Self { network }
|
||||
Self {
|
||||
network,
|
||||
#[cfg(feature = "tun")]
|
||||
shared_virtual_nic_registry: Instance::new_shared_virtual_nic_registry(),
|
||||
}
|
||||
}
|
||||
|
||||
fn new_instance(
|
||||
&self,
|
||||
config: TomlConfigLoader,
|
||||
process_runtime: Arc<CoreProcessRuntime>,
|
||||
) -> Instance {
|
||||
// Reconnect through the same registry, as NativeInstanceFactory does,
|
||||
// so old member cleanup and new registration share the TUN owner.
|
||||
#[cfg(feature = "tun")]
|
||||
{
|
||||
Instance::new_with_process_runtime_and_shared_virtual_nic_registry(
|
||||
config,
|
||||
process_runtime,
|
||||
self.shared_virtual_nic_registry.clone(),
|
||||
)
|
||||
}
|
||||
#[cfg(not(feature = "tun"))]
|
||||
Instance::new_with_process_runtime(config, process_runtime)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -701,8 +727,8 @@ async fn init_public_ipv6_two_node_with_topology(
|
||||
);
|
||||
client_cfg.set_ipv6_public_addr_auto(true);
|
||||
|
||||
let mut provider = Instance::new_with_process_runtime(provider_cfg, process_runtime.clone());
|
||||
let mut client = Instance::new_with_process_runtime(client_cfg, process_runtime.clone());
|
||||
let mut provider = lab.new_instance(provider_cfg, process_runtime.clone());
|
||||
let mut client = lab.new_instance(client_cfg, process_runtime.clone());
|
||||
|
||||
provider.run().await.unwrap();
|
||||
client.run().await.unwrap();
|
||||
@@ -984,7 +1010,7 @@ pub async fn public_ipv6_auto_addr_reconnect_reuses_same_address() {
|
||||
client_id,
|
||||
);
|
||||
client_cfg.set_ipv6_public_addr_auto(true);
|
||||
let mut client = Instance::new_with_process_runtime(client_cfg, process_runtime.clone());
|
||||
let mut client = lab.new_instance(client_cfg, process_runtime.clone());
|
||||
client.run().await.unwrap();
|
||||
provider.add_connector_url("tcp://10.1.1.2:11010".parse().unwrap());
|
||||
|
||||
@@ -2075,8 +2101,13 @@ pub async fn wireguard_vpn_portal(#[values(true, false)] test_v6: bool) {
|
||||
config
|
||||
.add_proxy_cidr("198.51.100.0/24".parse().unwrap(), None)
|
||||
.unwrap();
|
||||
let mut flags = config.get_flags();
|
||||
flags.enable_exit_node = true;
|
||||
flags.use_smoltcp = true;
|
||||
config.set_flags(flags);
|
||||
}
|
||||
if config.get_inst_name() == "inst3" {
|
||||
config.set_exit_nodes(vec!["10.144.144.1".parse().unwrap()]);
|
||||
config.set_vpn_portal_config(VpnPortalConfig {
|
||||
enabled: None,
|
||||
wireguard_listen: "0.0.0.0:22121".parse().unwrap(),
|
||||
@@ -2137,7 +2168,7 @@ pub async fn wireguard_vpn_portal(#[values(true, false)] test_v6: bool) {
|
||||
dst_socket_addr,
|
||||
Key::try_from(server_public.as_slice()).unwrap(),
|
||||
Key::try_from(client_private.as_slice()).unwrap(),
|
||||
vec!["10.144.144.0/24".to_string()],
|
||||
vec!["10.144.144.0/24".to_string(), "10.1.1.1/32".to_string()],
|
||||
"10.144.144.4".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -2161,6 +2192,14 @@ pub async fn wireguard_vpn_portal(#[values(true, false)] test_v6: bool) {
|
||||
)
|
||||
.await;
|
||||
|
||||
// This address is outside the mesh and advertised proxy CIDRs, on a
|
||||
// separate bridge reachable only through the configured exit node.
|
||||
wait_for_condition(
|
||||
|| async { ping_test(network.namespace("net_d"), "10.1.1.1", None).await },
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await;
|
||||
|
||||
drop_insts(insts).await;
|
||||
}
|
||||
|
||||
@@ -2840,6 +2879,161 @@ pub async fn foreign_network_functional_cluster() {
|
||||
drop_insts(vec![center_inst1, center_inst2, inst1, inst2]).await;
|
||||
}
|
||||
|
||||
#[rstest::rstest]
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn foreign_network_advertises_listeners_for_direct_connect(
|
||||
#[values("tcp", "udp")] protocol: &str,
|
||||
) {
|
||||
use easytier_proto::{
|
||||
peer_rpc::{DirectConnectorRpc, DirectConnectorRpcClientFactory, GetIpListRequest},
|
||||
rpc_types::controller::BaseController,
|
||||
};
|
||||
|
||||
let mut network = TestNetwork::new();
|
||||
network.create_bridge("br");
|
||||
let process_runtime = CoreProcessRuntime::new();
|
||||
let mut instances = Vec::new();
|
||||
for (index, name) in ["server_a", "server_b", "client_a", "client_b"]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
network.create_namespace(
|
||||
name,
|
||||
&format!("10.81.0.{}/24", index + 1),
|
||||
&format!("fd81::{}/64", index + 1),
|
||||
);
|
||||
network.add_to_bridge("br", name);
|
||||
let config = TomlConfigLoader::default();
|
||||
config.set_inst_name(name.to_owned());
|
||||
config.set_netns(Some(network.namespace(name).to_owned()));
|
||||
config.set_network_identity(NetworkIdentity::new(
|
||||
if index < 2 {
|
||||
"public-net"
|
||||
} else {
|
||||
"client-net"
|
||||
}
|
||||
.to_owned(),
|
||||
String::new(),
|
||||
));
|
||||
config.set_listeners(if index < 2 {
|
||||
// Keep listener discovery independent of interface collection in netns.
|
||||
vec![
|
||||
format!("{protocol}://10.81.0.{}:11010", index + 1)
|
||||
.parse()
|
||||
.unwrap(),
|
||||
]
|
||||
} else {
|
||||
Vec::new()
|
||||
});
|
||||
let mut flags = config.get_flags();
|
||||
flags.no_tun = true;
|
||||
flags.enable_ipv6 = false;
|
||||
flags.disable_udp_hole_punching = true;
|
||||
flags.disable_tcp_hole_punching = true;
|
||||
flags.relay_all_peer_rpc = true;
|
||||
if index == 1 {
|
||||
// The second server only relays RPC, as in the reported topology.
|
||||
flags.relay_network_whitelist = String::new();
|
||||
flags.disable_relay_data = true;
|
||||
}
|
||||
config.set_flags(flags);
|
||||
assert!(config.get_mapped_listeners().is_empty());
|
||||
let mut instance = Instance::new_with_process_runtime(config, process_runtime.clone());
|
||||
instance.run().await.unwrap();
|
||||
set_foreign_network_refresh_interval(&instance, 1).await;
|
||||
instances.push(instance);
|
||||
}
|
||||
let [server_a, server_b, client_a, client_b] = instances.as_slice() else {
|
||||
unreachable!()
|
||||
};
|
||||
// Bootstrap only the cluster link and each client's local server.
|
||||
// Connections to the other server must use its advertised TCP/UDP listener.
|
||||
server_a.add_connector_url(server_b.ring_listener_url());
|
||||
client_a.add_connector_url(server_a.ring_listener_url());
|
||||
client_b.add_connector_url(server_b.ring_listener_url());
|
||||
|
||||
for (client, remote_server) in [(client_a, server_b), (client_b, server_a)] {
|
||||
let client = client.get_core_instance();
|
||||
let remote_server = remote_server.get_core_instance();
|
||||
wait_for_condition_named(
|
||||
|| async {
|
||||
remote_server
|
||||
.foreign_network_snapshots(false)
|
||||
.await
|
||||
.contains_key("client-net")
|
||||
},
|
||||
Duration::from_secs(10),
|
||||
"client-net foreign network snapshot on remote server",
|
||||
)
|
||||
.await;
|
||||
let remote_peer_id = remote_server.foreign_network_snapshots(false).await["client-net"]
|
||||
.my_peer_id_for_this_network;
|
||||
wait_for_condition_named(
|
||||
|| async {
|
||||
client
|
||||
.route_snapshots()
|
||||
.await
|
||||
.iter()
|
||||
.any(|route| route.peer_id == remote_peer_id)
|
||||
},
|
||||
Duration::from_secs(10),
|
||||
"route to remote peer on client",
|
||||
)
|
||||
.await;
|
||||
// The reply is routed back by the remote server, so wait until it has
|
||||
// learned the client peer in the client network as well.
|
||||
let client_peer_id = client.peer_id();
|
||||
wait_for_condition_named(
|
||||
|| async {
|
||||
remote_server
|
||||
.foreign_network_has_gateway_for_test("client-net", client_peer_id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
},
|
||||
Duration::from_secs(10),
|
||||
"client peer learned by remote server in client-net",
|
||||
)
|
||||
.await;
|
||||
let rpc = client
|
||||
.peer_manager()
|
||||
.get_peer_rpc_mgr()
|
||||
.rpc_client()
|
||||
.scoped_client::<DirectConnectorRpcClientFactory<BaseController>>(
|
||||
client.peer_id(),
|
||||
remote_peer_id,
|
||||
"client-net".to_owned(),
|
||||
);
|
||||
let listeners = rpc
|
||||
.get_ip_list(BaseController::default(), GetIpListRequest {})
|
||||
.await
|
||||
.unwrap()
|
||||
.listeners
|
||||
.into_iter()
|
||||
.map(url::Url::from)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(listeners, remote_server.running_listeners());
|
||||
wait_for_condition_named(
|
||||
|| async {
|
||||
client.peer_snapshots().await.iter().any(|peer| {
|
||||
peer.peer_id == remote_peer_id
|
||||
&& peer.conns.iter().any(|conn| {
|
||||
conn.is_client
|
||||
&& conn
|
||||
.tunnel
|
||||
.as_ref()
|
||||
.is_some_and(|tunnel| tunnel.tunnel_type == protocol)
|
||||
})
|
||||
})
|
||||
},
|
||||
Duration::from_secs(10),
|
||||
"direct connection over {protocol}",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
drop_insts(instances).await;
|
||||
}
|
||||
|
||||
#[rstest::rstest]
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
@@ -3214,18 +3408,26 @@ pub async fn relay_bps_limit_test(#[values(100, 200, 400, 800)] bps_limit: u64)
|
||||
let tcp_listener = core_tcp_listener("tcp://0.0.0.0:22223".parse().unwrap());
|
||||
let tcp_connector = core_tcp_dialer("tcp://10.144.144.3:22223".parse().unwrap());
|
||||
|
||||
let bps = _tunnel_bench_netns(
|
||||
// The relay keys its forward limiter by the peer network name, which is
|
||||
// the dialing instance's identity, not inst2's own "public" one.
|
||||
let relay_network = insts[0].get_core_instance().network_name_for_test();
|
||||
let meter = || relay_forward_metered_bytes(&insts[1], &relay_network);
|
||||
|
||||
let (bps, metered) = _tunnel_bench_netns(
|
||||
tcp_listener,
|
||||
tcp_connector,
|
||||
NetNS::new(Some(network.namespace("net_c").into())),
|
||||
NetNS::new(Some(network.namespace("net_a").into())),
|
||||
Some(&meter),
|
||||
)
|
||||
.await;
|
||||
let metered = metered.expect("metered window should be sampled");
|
||||
|
||||
println!("bps: {}", bps);
|
||||
|
||||
let bps = bps as u64 / 1024;
|
||||
assert_limited_payload_bps(bps, bps_limit);
|
||||
println!(
|
||||
"payload bps: {}, metered: {} over {:?}",
|
||||
bps, metered.0, metered.1
|
||||
);
|
||||
assert_limited_bps(metered, bps, bps_limit);
|
||||
|
||||
drop_insts(insts).await;
|
||||
}
|
||||
@@ -3253,33 +3455,67 @@ pub async fn instance_recv_bps_limit_test(#[values(100, 800)] bps_limit: u64) {
|
||||
let tcp_listener = core_tcp_listener("tcp://0.0.0.0:22223".parse().unwrap());
|
||||
let tcp_connector = core_tcp_dialer("tcp://10.144.144.3:22223".parse().unwrap());
|
||||
|
||||
let bps = _tunnel_bench_netns(
|
||||
let meter = || instance_recv_metered_bytes(&insts[1]);
|
||||
|
||||
let (bps, metered) = _tunnel_bench_netns(
|
||||
tcp_listener,
|
||||
tcp_connector,
|
||||
NetNS::new(Some(network.namespace("net_c").into())),
|
||||
NetNS::new(Some(network.namespace("net_a").into())),
|
||||
Some(&meter),
|
||||
)
|
||||
.await;
|
||||
let metered = metered.expect("metered window should be sampled");
|
||||
|
||||
println!("bps: {}", bps);
|
||||
|
||||
let bps = bps as u64 / 1024;
|
||||
assert_limited_payload_bps(bps, bps_limit);
|
||||
println!(
|
||||
"payload bps: {}, metered: {} over {:?}",
|
||||
bps, metered.0, metered.1
|
||||
);
|
||||
assert_limited_bps(metered, bps, bps_limit);
|
||||
|
||||
drop_insts(insts).await;
|
||||
}
|
||||
|
||||
fn assert_limited_payload_bps(bps: u64, bps_limit: u64) {
|
||||
// The benchmark measures TCP application payload while the limiter counts
|
||||
// EasyTier data payload, including the inner IP and transport headers.
|
||||
let min_bps = bps_limit.saturating_sub((bps_limit / 10).max(50));
|
||||
let max_bps = bps_limit + 50;
|
||||
fn relay_forward_metered_bytes(inst: &Instance, network_name: &str) -> u64 {
|
||||
inst.get_core_instance()
|
||||
.foreign_forward_metered_bytes_for_test(network_name)
|
||||
.expect("relay forward limiter should be configured")
|
||||
}
|
||||
|
||||
fn instance_recv_metered_bytes(inst: &Instance) -> u64 {
|
||||
inst.get_core_instance()
|
||||
.instance_recv_metered_bytes_for_test()
|
||||
.expect("instance receive limiter should be configured")
|
||||
}
|
||||
|
||||
fn assert_limited_bps(metered: (u64, Duration), payload_bps: usize, bps_limit: u64) {
|
||||
// The limiter charges each packet with its EasyTier payload length, which
|
||||
// includes the inner IP and transport headers, so its counter is the rate
|
||||
// it actually enforced. The benchmark only sees TCP application payload,
|
||||
// so comparing the two directly made this assertion flaky. The window
|
||||
// starts once the initial bucket burst is drained.
|
||||
let (bytes, window) = metered;
|
||||
let expected = bps_limit as f64 * 1024.0 * window.as_secs_f64();
|
||||
let slack = expected * 0.05;
|
||||
assert!(
|
||||
bps >= min_bps && bps <= max_bps,
|
||||
"bps: {}, expected: {}..={}",
|
||||
bps,
|
||||
min_bps,
|
||||
max_bps
|
||||
(bytes as f64) >= expected - slack && (bytes as f64) <= expected + slack,
|
||||
"metered: {} over {:?}, expected: {:.0}..={:.0}",
|
||||
bytes,
|
||||
window,
|
||||
expected - slack,
|
||||
expected + slack
|
||||
);
|
||||
|
||||
// Encapsulation only shrinks the payload the receiver sees, so a limiter
|
||||
// whose rejection the forwarding path ignores still shows up here. The
|
||||
// benchmark rounds its window down to whole seconds, which inflates the
|
||||
// payload rate by a few percent, hence the same 50 KiB/s headroom.
|
||||
let payload_bps = payload_bps as u64 / 1024;
|
||||
assert!(
|
||||
payload_bps <= bps_limit + 50,
|
||||
"payload bps: {}, expected: <= {}",
|
||||
payload_bps,
|
||||
bps_limit + 50
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5400,14 +5636,45 @@ pub async fn dhcpv6_three_node_connectivity(#[case] dhcp_ipv4: bool, #[case] sta
|
||||
for address in &addresses {
|
||||
assert_eq!(address.network(), "fd42:1234::/64".parse().unwrap());
|
||||
}
|
||||
// DHCP can temporarily assign the same IPv4 address to multiple peers.
|
||||
// Wait for conflict resolution and route propagation in both directions
|
||||
// before opening connections; IPv6 readiness alone does not imply this.
|
||||
wait_for_condition(
|
||||
|| async {
|
||||
insts[0]
|
||||
.get_core_instance()
|
||||
.route_snapshots()
|
||||
.await
|
||||
let ipv4_addresses = insts
|
||||
.iter()
|
||||
.all(|route| route.ipv6_addr.is_some())
|
||||
.map(|inst| inst.get_global_ctx().get_ipv4())
|
||||
.collect::<Vec<_>>();
|
||||
if (dhcp_ipv4 || static_ipv4)
|
||||
&& ipv4_addresses
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|address| address.address())
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.len()
|
||||
!= insts.len()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
for inst in &insts {
|
||||
let routes = inst.get_core_instance().route_snapshots().await;
|
||||
if routes.len() != insts.len() - 1 {
|
||||
return false;
|
||||
}
|
||||
for (index, peer) in insts.iter().enumerate() {
|
||||
if peer.peer_id() == inst.peer_id() {
|
||||
continue;
|
||||
}
|
||||
if !routes.iter().any(|route| {
|
||||
route.peer_id == peer.peer_id()
|
||||
&& route.ipv6_addr == Some(addresses[index].into())
|
||||
&& route.ipv4_addr == ipv4_addresses[index].map(Into::into)
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
},
|
||||
Duration::from_secs(15),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use bon::builder;
|
||||
use network_interface::NetworkInterfaceConfig as _;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
|
||||
use crate::common::netns::NetNS;
|
||||
@@ -10,11 +9,18 @@ pub(crate) fn get_interface_name_by_ip(local_ip: &IpAddr) -> Option<String> {
|
||||
if local_ip.is_unspecified() || local_ip.is_multicast() {
|
||||
return None;
|
||||
}
|
||||
let ifaces = network_interface::NetworkInterface::show().ok()?;
|
||||
for iface in ifaces {
|
||||
for addr in iface.addr {
|
||||
#[cfg(target_os = "windows")]
|
||||
let ifaces = crate::common::network::get_interfaces_cached()?;
|
||||
// Enumerate under the caller's namespace guard on non-Windows platforms.
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let ifaces = {
|
||||
use network_interface::NetworkInterfaceConfig;
|
||||
network_interface::NetworkInterface::show().ok()?
|
||||
};
|
||||
for iface in ifaces.iter() {
|
||||
for addr in &iface.addr {
|
||||
if addr.ip() == *local_ip {
|
||||
return Some(iface.name);
|
||||
return Some(iface.name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -244,6 +250,8 @@ pub async fn bind<B: Bindable>(
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use atomic_shim::AtomicU64;
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::time::Duration;
|
||||
use std::{sync::Arc, time::Instant};
|
||||
|
||||
use anyhow::Context as _;
|
||||
@@ -261,6 +269,74 @@ pub(crate) mod tests {
|
||||
|
||||
use crate::common::netns::NetNS;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn interface_snapshot_lookup_smoke_test() {
|
||||
// The process-global cache may expire between reads. Identity checks
|
||||
// belong to the isolated helper tests in common::network instead.
|
||||
for _ in 0..2 {
|
||||
let ifaces = crate::common::network::get_interfaces_cached()
|
||||
.expect("interface lookup should work");
|
||||
assert!(!ifaces.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
// Diagnostic probe for the issue #2735 slow candidate loop: run with
|
||||
// `cargo test -p easytier --lib probe_interface_lookup -- --ignored --nocapture`.
|
||||
// Compare only IP-to-name and name-to-index lookups, not socket binding.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
#[ignore = "diagnostic probe; enumerates live Windows adapters"]
|
||||
fn probe_interface_lookup_latency_with_and_without_cache() {
|
||||
use network_interface::{NetworkInterface, NetworkInterfaceConfig};
|
||||
use std::net::IpAddr;
|
||||
|
||||
type EnumerateInterfaces = fn() -> Option<Arc<Vec<NetworkInterface>>>;
|
||||
|
||||
fn lookup_index(ip: IpAddr, enumerate: EnumerateInterfaces) -> Option<u32> {
|
||||
let ifaces = enumerate()?;
|
||||
let name = &ifaces
|
||||
.iter()
|
||||
.find(|iface| iface.addr.iter().any(|addr| addr.ip() == ip))?
|
||||
.name;
|
||||
let ifaces = enumerate()?;
|
||||
ifaces
|
||||
.iter()
|
||||
.find(|iface| iface.name == *name)
|
||||
.map(|iface| iface.index)
|
||||
}
|
||||
|
||||
let ifaces = NetworkInterface::show().unwrap();
|
||||
let candidates: Vec<_> = ifaces
|
||||
.iter()
|
||||
.flat_map(|iface| {
|
||||
iface.addr.iter().filter_map(move |addr| {
|
||||
matches!(addr.ip(), IpAddr::V4(_)).then(|| (iface.name.clone(), addr.ip()))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
println!("IPv4 lookup candidates ({}):", candidates.len());
|
||||
|
||||
let providers: [(&str, EnumerateInterfaces); 2] = [
|
||||
("uncached", || NetworkInterface::show().ok().map(Arc::new)),
|
||||
("cached", crate::common::network::get_interfaces_cached),
|
||||
];
|
||||
for (label, enumerate) in providers {
|
||||
let mut total = Duration::ZERO;
|
||||
for (name, ip) in &candidates {
|
||||
let start = Instant::now();
|
||||
let index = lookup_index(*ip, enumerate);
|
||||
let elapsed = start.elapsed();
|
||||
total += elapsed;
|
||||
println!(" [{label}] {ip:>16} ({name}): lookup {elapsed:?}, index={index:?}");
|
||||
}
|
||||
println!(
|
||||
"{label} lookup total: {total:?} ({} candidates)",
|
||||
candidates.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bind_rejects_occupied_ports() {
|
||||
for ip in ["127.0.0.1", "::1"] {
|
||||
@@ -571,7 +647,8 @@ pub(crate) mod tests {
|
||||
connector: C,
|
||||
netns_l: NetNS,
|
||||
netns_c: NetNS,
|
||||
) -> usize
|
||||
meter: Option<&(dyn Fn() -> u64 + Sync)>,
|
||||
) -> (usize, Option<(u64, std::time::Duration)>)
|
||||
where
|
||||
L: SocketListener<Accepted = Box<dyn Tunnel>> + Sync + 'static,
|
||||
C: TunnelDialer,
|
||||
@@ -618,11 +695,23 @@ pub(crate) mod tests {
|
||||
}
|
||||
|
||||
let now = Instant::now();
|
||||
let mut meter_base = None;
|
||||
while now.elapsed().as_secs() < 10 {
|
||||
// Skip the initial burst so the metered window reflects the
|
||||
// sustained rate the limiter enforces.
|
||||
if meter_base.is_none() && now.elapsed() >= std::time::Duration::from_secs(1) {
|
||||
meter_base = meter.map(|meter| (meter(), now.elapsed()));
|
||||
}
|
||||
// send.feed(item)
|
||||
let item = ZCPacket::new_with_payload(send_buf.as_ref());
|
||||
send.feed(item).await.unwrap();
|
||||
}
|
||||
let metered = match (meter_base, meter) {
|
||||
(Some((metered, started)), Some(meter)) => {
|
||||
Some((meter().saturating_sub(metered), now.elapsed() - started))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
send.close().await.unwrap();
|
||||
drop(send);
|
||||
@@ -634,13 +723,25 @@ pub(crate) mod tests {
|
||||
println!("bps: {}", bps);
|
||||
|
||||
lis.abort();
|
||||
bps as usize
|
||||
(bps as usize, metered)
|
||||
}
|
||||
|
||||
pub async fn wait_for_condition<F, FRet>(mut condition: F, timeout: std::time::Duration)
|
||||
pub async fn wait_for_condition<F, FRet>(condition: F, timeout: std::time::Duration)
|
||||
where
|
||||
F: FnMut() -> FRet + Send,
|
||||
FRet: Future<Output = bool>,
|
||||
{
|
||||
wait_for_condition_named(condition, timeout, "condition").await
|
||||
}
|
||||
|
||||
/// Like [`wait_for_condition`], but the panic names the gate that timed out.
|
||||
pub async fn wait_for_condition_named<F, FRet>(
|
||||
mut condition: F,
|
||||
timeout: std::time::Duration,
|
||||
what: &str,
|
||||
) where
|
||||
F: FnMut() -> FRet + Send,
|
||||
FRet: Future<Output = bool>,
|
||||
{
|
||||
let now = std::time::Instant::now();
|
||||
while now.elapsed() < timeout {
|
||||
@@ -649,6 +750,6 @@ pub(crate) mod tests {
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
}
|
||||
assert!(condition().await, "Timeout")
|
||||
assert!(condition().await, "Timeout waiting for {what}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -609,12 +609,8 @@ impl WgTunnelListener {
|
||||
};
|
||||
|
||||
tracing::info!("New peer: {}", addr);
|
||||
let mut wg = WgPeer::new(
|
||||
Box::new(session_listener.clone()),
|
||||
session,
|
||||
config.clone(),
|
||||
addr,
|
||||
);
|
||||
// Accepted sessions already keep their UDP layer alive until close.
|
||||
let mut wg = WgPeer::new(Box::new(()), session, config.clone(), addr);
|
||||
let (stream, sink) = wg.start_and_get_tunnel().split();
|
||||
wg.spawn_session_recv_task(None);
|
||||
let tunnel = Box::new(TunnelWrapper::new(
|
||||
@@ -843,13 +839,32 @@ pub mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bind_same_port() {
|
||||
let server_cfg = test_wg_config();
|
||||
let mut listener = WgTunnelListener::new("wg://[::1]:31015".parse().unwrap(), server_cfg);
|
||||
let server_cfg = test_wg_config();
|
||||
let mut listener2 = WgTunnelListener::new("wg://[::1]:31015".parse().unwrap(), server_cfg);
|
||||
async fn bind_ipv4_and_ipv6_same_port() {
|
||||
let mut listener = WgTunnelListener::new("wg://[::]:0".parse().unwrap(), test_wg_config());
|
||||
listener.listen().await.unwrap();
|
||||
let port = listener.local_url().port().unwrap();
|
||||
|
||||
let mut listener2 = WgTunnelListener::new(
|
||||
format!("wg://0.0.0.0:{port}").parse().unwrap(),
|
||||
test_wg_config(),
|
||||
);
|
||||
listener2.listen().await.unwrap();
|
||||
assert_eq!(listener2.local_url().port(), Some(port));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bind_same_port_is_rejected() {
|
||||
let server_cfg = test_wg_config();
|
||||
let mut listener = WgTunnelListener::new("wg://[::1]:0".parse().unwrap(), server_cfg);
|
||||
listener.listen().await.unwrap();
|
||||
|
||||
let mut listener2 = WgTunnelListener::new(listener.local_url(), test_wg_config());
|
||||
let error = listener2.listen().await.unwrap_err();
|
||||
assert!(error.chain().any(|cause| {
|
||||
cause
|
||||
.downcast_ref::<std::io::Error>()
|
||||
.is_some_and(|error| error.kind() == std::io::ErrorKind::AddrInUse)
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -9,7 +9,7 @@ mod engine;
|
||||
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fmt,
|
||||
fmt, io,
|
||||
net::{Ipv6Addr, SocketAddr, SocketAddrV6},
|
||||
sync::{Arc, Mutex as StdMutex, RwLock, Weak},
|
||||
};
|
||||
@@ -32,16 +32,51 @@ use sha2::Sha256;
|
||||
use tokio::{sync::mpsc, task::JoinSet};
|
||||
|
||||
use crate::{
|
||||
common::global_ctx::ArcGlobalCtx,
|
||||
host_runtime::{NativeHostRuntime, native_host_runtime},
|
||||
common::global_ctx::ArcGlobalCtx, host_runtime::native_host_runtime,
|
||||
socket::udp::RuntimeUdpSocket,
|
||||
};
|
||||
|
||||
use self::engine::{DerivedClient, PortalEngine};
|
||||
|
||||
// Endpoints retain this family-specific slot, not a socket generation. Holding
|
||||
// the read guard during send lets the reader release the old binding on failure.
|
||||
struct PortalSocket {
|
||||
socket: tokio::sync::RwLock<Option<Arc<RuntimeUdpSocket>>>,
|
||||
options: UdpBindOptions,
|
||||
}
|
||||
|
||||
impl PortalSocket {
|
||||
async fn send_to(&self, data: &[u8], remote: SocketAddr) -> io::Result<usize> {
|
||||
let socket = self.socket.read().await;
|
||||
match socket.as_ref() {
|
||||
Some(socket) => socket.send_to(data, remote).await,
|
||||
None => Err(io::Error::new(
|
||||
io::ErrorKind::WouldBlock,
|
||||
"portal socket is rebinding",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn rebind(&self) {
|
||||
self.socket.write().await.take();
|
||||
loop {
|
||||
easytier_core::socket::retry::wait().await;
|
||||
match native_host_runtime().bind_udp(self.options.clone()).await {
|
||||
Ok(socket) => {
|
||||
*self.socket.write().await = Some(socket);
|
||||
return;
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(target: "CORE", %error, address = ?self.options.local_addr, "WireGuard portal UDP bind failed; retrying")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WireGuardPortalListener {
|
||||
url: url::Url,
|
||||
sockets: Vec<Arc<RuntimeUdpSocket>>,
|
||||
sockets: Vec<Arc<PortalSocket>>,
|
||||
receiver: mpsc::UnboundedReceiver<PortalSession>,
|
||||
engine: Arc<PortalEngine>,
|
||||
tasks: JoinSet<anyhow::Result<()>>,
|
||||
@@ -66,15 +101,24 @@ impl SocketListener for WireGuardPortalListener {
|
||||
return Ok(());
|
||||
}
|
||||
self.listened = true;
|
||||
for socket in &self.sockets {
|
||||
let socket = socket.clone();
|
||||
for socket in self.sockets.drain(..) {
|
||||
let engine = self.engine.clone();
|
||||
self.tasks.spawn(async move {
|
||||
loop {
|
||||
let datagram = socket
|
||||
.recv_session_datagram()
|
||||
.await
|
||||
.context("WireGuard portal UDP receive failed")?;
|
||||
let datagram = {
|
||||
// Do not hold a read lock while waiting for traffic, or
|
||||
// retain this generation while trying to rebind its port.
|
||||
let current = socket.socket.read().await.as_ref().unwrap().clone();
|
||||
current.recv_session_datagram().await
|
||||
};
|
||||
let datagram = match datagram {
|
||||
Ok(datagram) => datagram,
|
||||
Err(error) => {
|
||||
tracing::warn!(target: "CORE", %error, address = ?socket.options.local_addr, "WireGuard portal UDP socket failed; rebinding");
|
||||
socket.rebind().await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
engine
|
||||
.handle_datagram(socket.clone(), datagram.remote_addr, &datagram.payload)
|
||||
.await;
|
||||
@@ -191,20 +235,21 @@ impl WireGuardPortalHost {
|
||||
|
||||
async fn bind_socket(
|
||||
&self,
|
||||
runtime: &NativeHostRuntime,
|
||||
address: SocketAddr,
|
||||
only_v6: bool,
|
||||
) -> anyhow::Result<Arc<RuntimeUdpSocket>> {
|
||||
) -> anyhow::Result<Arc<PortalSocket>> {
|
||||
let context = SocketContext::default()
|
||||
.with_socket_mark(self.global_ctx.get_flags().socket_mark)
|
||||
.with_netns(self.global_ctx.net_ns.name().map(NetNamespace::new));
|
||||
runtime
|
||||
.bind_udp(
|
||||
UdpBindOptions::port_bound_listener(address)
|
||||
.with_context(context)
|
||||
.with_only_v6(only_v6),
|
||||
)
|
||||
.await
|
||||
let mut options = UdpBindOptions::port_bound_listener(address)
|
||||
.with_context(context)
|
||||
.with_only_v6(only_v6);
|
||||
let socket = native_host_runtime().bind_udp(options.clone()).await?;
|
||||
options.local_addr = Some(socket.local_addr()?);
|
||||
Ok(Arc::new(PortalSocket {
|
||||
socket: tokio::sync::RwLock::new(Some(socket)),
|
||||
options,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn apply_client_updates(&self, clients: &[PortalClientConfig]) -> anyhow::Result<()> {
|
||||
@@ -285,16 +330,15 @@ impl PortalHost for WireGuardPortalHost {
|
||||
.setup
|
||||
.as_ref()
|
||||
.map_err(|error| anyhow::anyhow!(error.clone()))?;
|
||||
let runtime = native_host_runtime();
|
||||
let primary = self
|
||||
.bind_socket(&runtime, self.config.wireguard_listen, false)
|
||||
.bind_socket(self.config.wireguard_listen, false)
|
||||
.await
|
||||
.context("failed to bind WireGuard VPN portal")?;
|
||||
let local = primary.local_addr()?;
|
||||
let mut sockets = vec![primary.clone()];
|
||||
let local = primary.options.local_addr.unwrap();
|
||||
let mut sockets = vec![primary];
|
||||
if let Some(v6_address) =
|
||||
secondary_ipv6_bind_address(self.config.wireguard_listen, local.port())
|
||||
&& let Ok(v6) = self.bind_socket(&runtime, v6_address, true).await
|
||||
&& let Ok(v6) = self.bind_socket(v6_address, true).await
|
||||
{
|
||||
sockets.push(v6);
|
||||
}
|
||||
@@ -391,6 +435,58 @@ pub(crate) fn test_wireguard_keys(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn portal_socket_rebinds_while_endpoints_keep_their_reference() {
|
||||
let client = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let mut options = UdpBindOptions::port_bound_listener("127.0.0.1:0".parse().unwrap())
|
||||
.with_need_protect(false);
|
||||
let initial = native_host_runtime()
|
||||
.bind_udp(options.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let local = initial.local_addr().unwrap();
|
||||
options.local_addr = Some(local);
|
||||
let old_socket = Arc::downgrade(&initial);
|
||||
let socket = Arc::new(PortalSocket {
|
||||
socket: tokio::sync::RwLock::new(Some(initial)),
|
||||
options,
|
||||
});
|
||||
let endpoint = socket.clone();
|
||||
let mut rebind = Box::pin(socket.rebind());
|
||||
assert!(futures::poll!(&mut rebind).is_pending());
|
||||
assert!(old_socket.upgrade().is_none());
|
||||
assert_eq!(
|
||||
endpoint
|
||||
.send_to(b"during-rebind", client.local_addr().unwrap())
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
io::ErrorKind::WouldBlock
|
||||
);
|
||||
assert!(
|
||||
tokio::time::timeout(std::time::Duration::from_millis(10), rebind)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(socket.socket.read().await.is_none());
|
||||
socket.rebind().await;
|
||||
|
||||
endpoint
|
||||
.send_to(b"after-rebind", client.local_addr().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut buffer = [0; 32];
|
||||
let (len, source) = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(1),
|
||||
client.recv_from(&mut buffer),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(&buffer[..len], b"after-rebind");
|
||||
assert_eq!(source, local);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_wireguard_keys_are_stable_and_client_scoped() {
|
||||
let master = [7; 32];
|
||||
|
||||
@@ -18,10 +18,7 @@ use boringtun::{
|
||||
},
|
||||
x25519::{PublicKey, StaticSecret},
|
||||
};
|
||||
use easytier_core::{
|
||||
gateway::vpn_portal::{PortalClientConfig, PortalSession},
|
||||
socket::udp::VirtualUdpSocket,
|
||||
};
|
||||
use easytier_core::gateway::vpn_portal::{PortalClientConfig, PortalSession};
|
||||
use rand::rngs::OsRng;
|
||||
use tokio::{
|
||||
sync::{Mutex, mpsc, watch},
|
||||
@@ -29,7 +26,7 @@ use tokio::{
|
||||
};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::socket::udp::RuntimeUdpSocket;
|
||||
use super::PortalSocket;
|
||||
const MIN_WIREGUARD_PACKET_CAPACITY: usize = 148;
|
||||
// We pre-verify through this shared limiter and BoringTun verifies again inside
|
||||
// each Tunn. Doubling the threshold preserves the intended 100 datagrams/s
|
||||
@@ -95,12 +92,12 @@ impl EngineSlots {
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Endpoint {
|
||||
socket: Arc<RuntimeUdpSocket>,
|
||||
socket: Arc<PortalSocket>,
|
||||
remote: SocketAddr,
|
||||
}
|
||||
|
||||
impl ClientSession {
|
||||
fn update_endpoint(&mut self, socket: Arc<RuntimeUdpSocket>, remote: SocketAddr) {
|
||||
fn update_endpoint(&mut self, socket: Arc<PortalSocket>, remote: SocketAddr) {
|
||||
let changed = self
|
||||
.endpoint
|
||||
.as_ref()
|
||||
@@ -223,7 +220,7 @@ impl PortalEngine {
|
||||
|
||||
pub(super) async fn handle_datagram(
|
||||
self: &Arc<Self>,
|
||||
socket: Arc<RuntimeUdpSocket>,
|
||||
socket: Arc<PortalSocket>,
|
||||
remote: SocketAddr,
|
||||
datagram: &[u8],
|
||||
) {
|
||||
@@ -375,7 +372,7 @@ impl PortalEngine {
|
||||
fn new_session(
|
||||
self: &Arc<Self>,
|
||||
slot: &Arc<ClientSlot>,
|
||||
socket: Arc<RuntimeUdpSocket>,
|
||||
socket: Arc<PortalSocket>,
|
||||
remote: SocketAddr,
|
||||
) -> ClientSession {
|
||||
let generation = slot.next_generation.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -385,7 +382,7 @@ impl PortalEngine {
|
||||
let engine = Arc::downgrade(self);
|
||||
let slot_for_task = Arc::downgrade(slot);
|
||||
let mut tasks = JoinSet::new();
|
||||
tasks.spawn(async move {
|
||||
let forward_packets = async move {
|
||||
while let Some(payload) = to_client.recv().await {
|
||||
let Some(engine) = engine.upgrade() else {
|
||||
return;
|
||||
@@ -400,6 +397,13 @@ impl PortalEngine {
|
||||
if let (Some(engine), Some(slot)) = (engine.upgrade(), slot_for_task.upgrade()) {
|
||||
engine.expire_if_current(slot, generation).await;
|
||||
}
|
||||
};
|
||||
let cancel = self.cancel.clone();
|
||||
tasks.spawn(async move {
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => {},
|
||||
_ = forward_packets => {},
|
||||
}
|
||||
});
|
||||
ClientSession {
|
||||
generation,
|
||||
|
||||
@@ -10,7 +10,10 @@ cd "$repository_root"
|
||||
readonly target_dir="${CARGO_TARGET_DIR:-target}"
|
||||
readonly raw_artifact="${target_dir}/wasm32-wasip1/release/easytier_core.wasm"
|
||||
readonly artifact="${target_dir}/wasm32-wasip1/release/easytier_core_go_host.wasm"
|
||||
readonly core_features="management-rpc,proxy-smoltcp-stack,ring-crypto,wasi-crypto-offload"
|
||||
# The WASI host performs AEAD for the guest, and the guest has no TLS stack of its own, so the
|
||||
# default feature set (endpoint-discovery -> rustls -> ring) must stay off: ring compiles C
|
||||
# shims into wasm32-wasip1 that become env.ring_core_* imports, which the Go host cannot provide.
|
||||
readonly core_features="management,proxy-smoltcp-stack,wasi-crypto-offload,aes-gcm,tcp-hole-punch"
|
||||
|
||||
sha256_file() {
|
||||
if command -v sha256sum >/dev/null; then
|
||||
@@ -120,6 +123,7 @@ if [[ "$version" != *"version ${binaryen_version} "* ]]; then
|
||||
fi
|
||||
|
||||
cargo build --release --target wasm32-wasip1 -p easytier-core \
|
||||
--no-default-features \
|
||||
--features "$core_features"
|
||||
|
||||
optimized="${artifact}.wasm-opt.$$"
|
||||
|
||||
Reference in New Issue
Block a user